ATProto for TCA applications in Swift
0

Configure Feed

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

tests n stuff

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

+193 -287
+10 -10
Package.resolved
··· 1 1 { 2 - "originHash" : "5b2f59cadc8969e4ec377bd6040e38e7477e225e074cc34941df53fe4509bb62", 2 + "originHash" : "971312876e09d1a88e416bc433e49003860302151a508a615396a0f7c7b5fcbb", 3 3 "pins" : [ 4 4 { 5 5 "identity" : "combine-schedulers", ··· 13 13 { 14 14 "identity" : "swift-android-native", 15 15 "kind" : "remoteSourceControl", 16 - "location" : "https://github.com/skiptools/swift-android-native", 16 + "location" : "https://source.skip.tools/swift-android-native", 17 17 "state" : { 18 18 "revision" : "7e6e833e6f163a2b75340f75c70b1d96ea6b8135", 19 19 "version" : "1.5.1" ··· 33 33 "kind" : "remoteSourceControl", 34 34 "location" : "https://github.com/apple/swift-async-dns-resolver", 35 35 "state" : { 36 - "revision" : "36eedf108f9eda4feeaf47f3dfce657a88ef19aa", 37 - "version" : "0.6.0" 36 + "revision" : "9900d9b4113427a170d5f26075c2128be94984df", 37 + "version" : "0.7.0" 38 38 } 39 39 }, 40 40 { ··· 103 103 { 104 104 "identity" : "swift-dependencies-http-client", 105 105 "kind" : "remoteSourceControl", 106 - "location" : "https://github.com/woodymelling/swift-dependencies-http-client", 106 + "location" : "https://tangled.org/woody.fm/swift-dependencies-http-client", 107 107 "state" : { 108 - "revision" : "2c01d8b415a6594c0e5dbd5be81b9dc8d1771f56", 109 - "version" : "0.2.1" 108 + "branch" : "main", 109 + "revision" : "8f9ea05ad21473973a7e5f50636f0840258556ae" 110 110 } 111 111 }, 112 112 { ··· 168 168 "kind" : "remoteSourceControl", 169 169 "location" : "https://github.com/swiftlang/swift-syntax", 170 170 "state" : { 171 - "revision" : "0687f71944021d616d34d922343dcef086855920", 172 - "version" : "600.0.1" 171 + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", 172 + "version" : "602.0.0" 173 173 } 174 174 }, 175 175 { ··· 196 196 "location" : "https://github.com/pointfreeco/TCA26", 197 197 "state" : { 198 198 "branch" : "main", 199 - "revision" : "bc15f0a61950966c08322699c548f91113d67746" 199 + "revision" : "43bf5315c30477e9b800aa7859dbeda5079f402b" 200 200 } 201 201 }, 202 202 {
+7 -18
Package.swift
··· 18 18 19 19 .library(name: "DNSResolverClient", targets: ["DNSResolverClient"]), 20 20 .library(name: "XRPCClient", targets: ["XRPCClient"]), 21 - .library(name: "ATProtoSession", targets: ["ATProtoSession"]), 22 21 .library(name: "ATProtoOAuth", targets: ["ATProtoOAuth"]), 23 22 24 - .library(name: "TCATProtoOAuth", targets: ["TCATProtoOAuth"]) 23 + .library(name: "TCATProtoAuthentication", targets: ["TCATProtoAuthentication"]) 25 24 ], 26 25 dependencies: [ 27 26 .package(url: "https://github.com/apple/swift-async-dns-resolver", from: "0.5.0"), ··· 29 28 .package(url: "https://github.com/pointfreeco/swift-tagged.git", .upToNextMajor(from: "0.10.0")), 30 29 .package(url: "https://github.com/pointfreeco/swift-sharing", .upToNextMajor(from: "2.0.0")), 31 30 .package(url: "https://github.com/pointfreeco/swift-dependencies", branch: "26"), 32 - .package(path: "../3rd-party/PointFree/TCA26"), 31 + .package(url: "https://github.com/pointfreeco/TCA26", branch: "main"), 32 + // .package(path: "../3rd-party/PointFree/TCA26"), 33 33 34 - .package(url: "https://github.com/woodymelling/swift-dependencies-http-client", from: "0.2.1"), 34 + .package(url: "https://tangled.org/woody.fm/swift-dependencies-http-client", branch: "main"), 35 35 36 36 .package(url: "https://tangled.org/woody.fm/swift-urlqueryitem-coder", branch: "main"), 37 37 .package(path: "../swift-tagged-types-macro"), ··· 64 64 ] 65 65 ), 66 66 67 - 68 67 .target( 69 68 name: "XRPCClient", 70 69 dependencies: [ ··· 79 78 ), 80 79 81 80 .target( 82 - name: "ATProtoSession", 83 - dependencies: [ 84 - "ATProto", 85 - "XRPCClient", 86 - .product(name: "TaggedTypesMacro", package: "swift-tagged-types-macro"), 87 - ] 88 - ), 89 - 90 - .target( 91 81 name: "ATProtoOAuth", 92 82 dependencies: [ 93 83 "ATProto", ··· 100 90 ] 101 91 ), 102 92 .target( 103 - name: "TCATProtoOAuth", 93 + name: "TCATProtoAuthentication", 104 94 dependencies: [ 105 95 "ATProtoOAuth", 106 96 .product(name: "Sharing", package: "swift-sharing"), ··· 112 102 dependencies: [ 113 103 "ATProto", 114 104 .product(name: "DependenciesTestSupport", package: "swift-dependencies"), 115 - 116 105 ] 117 106 ), 118 107 ··· 133 122 ] 134 123 ), 135 124 .testTarget( 136 - name: "TCATProtoOAuthTests", 125 + name: "TCATProtoAuthenticationTests", 137 126 dependencies: [ 138 - "TCATProtoOAuth", 127 + "TCATProtoAuthentication", 139 128 .product(name: "DependenciesTestSupport", package: "swift-dependencies"), 140 129 .product(name: "ComposableArchitecture2", package: "TCA26"), 141 130 ]
+8
Sources/ATProtoOAuth/Exports.swift
··· 1 + // 2 + // Exports.swift 3 + // tcatproto 4 + // 5 + // Created by Woodrow Melling on 4/14/26. 6 + // 7 + 8 + @_exported import ATProto
+6 -1
Sources/ATProtoOAuth/OAuthClient.swift
··· 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 let pkceChallenge = try OAuth.PKCE.Challenge() 139 143 140 144 @Dependency(\.uuid) var uuid ··· 236 240 _ appID: OAuth.App.ID 237 241 ) async throws -> OAuth.Token.Response { 238 242 239 - let request = OAuth.Token.RefreshRequest(refreshToken: session.refreshToken, appID: appID) 243 + guard let refreshToken = session.refreshToken else { throw AuthorizationError.noRefreshToken } 244 + let request = OAuth.Token.RefreshRequest(refreshToken: refreshToken, appID: appID) 240 245 241 246 242 247 @Dependency(\.httpClient) var httpClient
+24 -3
Sources/ATProtoOAuth/Session.swift
··· 16 16 public let tokenEndpoint: OAuth.AuthorizationServer.TokenEndpoint 17 17 public let scope: OAuth.Scope 18 18 public let accessToken: OAuth.AccessToken 19 - public let refreshToken: OAuth.RefreshToken 19 + public let refreshToken: OAuth.RefreshToken? 20 20 public let dpopKey: OAuth.DPOP.Key 21 21 22 22 public struct Pending: Sendable { ··· 52 52 } 53 53 } 54 54 55 + /// Authenticates the given operation using the current `defaultSession` dependency. 56 + /// 57 + /// Adds DPoP proof and `Authorization: DPoP <token>` headers to all HTTP requests 58 + /// made within the operation. 59 + public func withDefaultSessionAuthentication<T>( 60 + operation: () async throws -> T 61 + ) 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) 69 + } 70 + 71 + struct NoSessionError: Error {} 55 72 // Inject the current authenticated session into the dependency graph so that 56 73 // any feature running inside an authenticated scope can read it without having 57 74 // to thread it through state manually. ··· 62 79 } 63 80 } 64 81 65 - private enum DefaultSessionKey: TestDependencyKey { 66 - static let testValue = OAuth.Session( 82 + public enum DefaultSessionKey: TestDependencyKey { 83 + public static let testValue: OAuth.Session = .placeholder 84 + } 85 + 86 + extension OAuth.Session { 87 + public static let placeholder = OAuth.Session( 67 88 accountID: .init(DID("did:plc:placeholder")!), 68 89 tokenEndpoint: .init(URL(string: "https://placeholder.example/oauth/token")!), 69 90 scope: "atproto",
+1 -1
Sources/ATProtoOAuth/Token.swift
··· 45 45 46 46 public struct Response: Sendable, Equatable, Decodable { 47 47 public let accessToken: OAuth.AccessToken 48 - public let refreshToken: OAuth.RefreshToken 48 + public let refreshToken: OAuth.RefreshToken? 49 49 public let tokenType: OAuth.Token.Kind 50 50 public let expiresIn: TimeInterval 51 51 public let scope: OAuth.Scope
-114
Sources/ATProtoSession/ATProtoSession.swift
··· 1 - // 2 - // ATProtoSession.swift 3 - // tcatproto 4 - // 5 - // Created by Woodrow Melling on 4/7/26. 6 - // 7 - 8 - import ATProto 9 - import XRPCClient 10 - import Tagged 11 - import TaggedTypesMacro 12 - 13 - extension ATProto { 14 - // MARK: Server 15 - enum Server { 16 - struct CreateSession: XRPC.Request { 17 - static let nsid: ATProto.NSID = "com.atproto.server.createSession" 18 - static let type: XRPC.RequestType = .procedure 19 - 20 - 21 - var input: Input 22 - 23 - 24 - public init(input: Input) { 25 - self.input = input 26 - } 27 - 28 - struct Input: Encodable { 29 - let identifier: ID 30 - let password: Atmosphere.Account.Password 31 - let authFactorToken: String 32 - let allowTakedown = false 33 - } 34 - 35 - struct Output: Decodable { 36 - let accessJwt: ATProto.LegacySession.AccessToken 37 - let refreshJwt: ATProto.LegacySession.RefreshToken 38 - let handle: Atmosphere.Account.Handle 39 - let did: DID 40 - 41 - let didDoc: DID.Document? 42 - 43 - let email: Atmosphere.Account.Email? 44 - let emailConfirmed: Bool? 45 - let emailAuthFactor: Bool? 46 - 47 - let active: Bool 48 - 49 - enum Status: String, Decodable { 50 - case takendown, suspended, deactivated 51 - } 52 - } 53 - } 54 - } 55 - } 56 - 57 - import DependenciesMacros 58 - import Dependencies 59 - 60 - extension ATProto.Server { 61 - @DependencyClient 62 - struct Client: Sendable { 63 - var createSession: @Sendable (CreateSession.Input) async throws -> CreateSession.Output 64 - } 65 - } 66 - 67 - extension ATProto.Server.Client: DependencyKey { 68 - static let testValue: ATProto.Server.Client = Self() 69 - static let liveValue: ATProto.Server.Client = .init( 70 - createSession: { 71 - @Dependency(XRPC.Client.self) var xrpcCient 72 - let request = ATProto.Server.CreateSession(input: $0) 73 - return try await xrpcCient.run(request) 74 - } 75 - ) 76 - } 77 - 78 - extension ATProto.Server.CreateSession { 79 - enum ID: Encodable { 80 - case handle(Atmosphere.Account.Handle) 81 - case did(DID) 82 - case email(Atmosphere.Account.Email) 83 - 84 - func encode(to encoder: any Encoder) throws { 85 - var container = encoder.singleValueContainer() 86 - switch self { 87 - case .handle(let h): try container.encode(h.rawValue) 88 - case .did(let d): try container.encode(d.description) 89 - case .email(let e): try container.encode(e) 90 - } 91 - } 92 - } 93 - } 94 - 95 - extension Atmosphere.Account { 96 - @TaggedTypes public enum _Tags { 97 - @Tagged(String.self) public enum Email {} 98 - @Tagged(String.self) public enum Password {} 99 - } 100 - } 101 - 102 - extension ATProto { 103 - struct LegacySession { 104 - @TaggedTypes public enum _Tags { 105 - @Tagged(String.self) public enum AccessToken {} 106 - @Tagged(String.self) public enum RefreshToken {} 107 - } 108 - 109 - let did: DID 110 - } 111 - } 112 - 113 - 114 -
Sources/TCATProtoOAuth/LocalCallbackServer.swift Sources/TCATProtoAuthentication/LocalCallbackServer.swift
+1 -1
Sources/TCATProtoOAuth/OpenURLWhenActiveSceneModifier.swift Sources/TCATProtoAuthentication/OpenURLWhenActiveSceneModifier.swift
··· 46 46 - Parameter action: A closure that takes a URL and performs an action. 47 47 - Returns: A view that handles URL opens when the app becomes active. 48 48 */ 49 - func onOpenURLWhenSceneActive(perform action: @escaping (URL) -> Void) -> some View { 49 + public func onOpenURLWhenSceneActive(perform action: @escaping (URL) -> Void) -> some View { 50 50 self.modifier(OpenURLWhenActiveSceneModifier(action: action)) 51 51 } 52 52 }
+102 -45
Sources/TCATProtoOAuth/TCATProtoAuth.swift Sources/TCATProtoAuthentication/TCATProtoAuth.swift
··· 39 39 @Shared var session: OAuth.Session? 40 40 let makeDestinationFeature: @Sendable () -> (LoggedInDestination) 41 41 42 + public init(session: Shared<OAuth.Session?>, makeDestinationFeature: @Sendable @escaping () -> LoggedInDestination) { 43 + self._session = session 44 + self.makeDestinationFeature = makeDestinationFeature 45 + } 42 46 43 47 44 48 // MARK: - State 45 49 public struct State { 46 50 @DebugSnapshotIgnored 47 - public var loggedIn: Spawn<LoggedInDestination>? 51 + public var loggedIn: LoggedInDestination.State? 48 52 49 53 50 54 var pendingSession: OAuth.Session.Pending? 51 - 52 - 53 55 var makeState: @Sendable () -> LoggedInDestination.State 54 56 55 - public mutating func startLogin(hint loginHint: String? = nil) { 56 - self.loginRequest = .init(hint: loginHint) 57 - } 57 + @Trigger<String?> public var startLogin 58 58 59 - public var loginRequest: LoginRequest? 60 - public struct LoginRequest: Equatable { 61 - public init(hint: String?) { 62 - self.hint = hint 63 - } 64 - let hint: String? 65 - } 66 59 public init( 67 - makeState: @escaping @Sendable () -> LoggedInDestination.State 60 + @FeatureBuilder<LoggedInDestination.State, LoggedInDestination.Action> makeState: @escaping @Sendable () -> LoggedInDestination.State 68 61 ) { 69 62 self.makeState = makeState 70 63 } ··· 75 68 @CasePathable 76 69 public enum Action { 77 70 case onOpenCallbackURL(OAuth.App.CallbackURI) 71 + case loggedIn(LoggedInDestination.Action) 78 72 } 79 73 80 74 @Dependency(OAuth.Client.self) var authClient ··· 94 88 95 89 $session.withLock { $0 = session } 96 90 } 91 + case .loggedIn: 92 + return 97 93 } 98 94 } 99 95 100 - .onMount(id: session) { state in 96 + .onMount(id: session?.accountID) { state in 97 + // print("sessionAccountID: \(session?.accountID)") 101 98 withErrorReporting { 102 99 if let session = session, state.loggedIn == nil { 103 - try store.modify { $0.loggedIn = Spawn(state.makeState()) } 100 + let loggedIn = state.makeState() 101 + state.loggedIn = loggedIn 104 102 try store.post(key: SessionMount.self, value: session.accountID) 105 103 } else { 106 104 try store.modify { 107 - $0.loggedIn = nil 108 105 $0.loggedIn = nil 109 106 } 110 107 try store.post(key: SessionDismount.self, value: nil) ··· 112 109 } 113 110 114 111 } 115 - .ifLet(\.loggedIn) { 112 + .ifLet(\.loggedIn, action: \.loggedIn) { 113 + let _ = print("making destination with sessionID: \(session?.accountID)") 116 114 makeDestinationFeature() 117 - .transformDependency(\.defaultSession) { 118 - if let session = session { 119 - $0 = session 120 - } 121 - } 115 + .dependency(\.defaultSession, session ?? .placeholder) 116 + let _ = print("After making destination") 122 117 } 123 118 .onEvent(LogoutRequested.self) { _, _ in 124 119 $session.withLock { $0 = nil } 125 120 } 126 - .onMount(id: store.loginRequest) { request in 127 - 128 - // .onTrigger(store.beginLogin) { triggerValue, state in 129 - 130 - let loginHintString = store.loginRequest?.hint 121 + .onTrigger(store.startLogin) { loginHintString, state in 131 122 store.addTask { 132 123 if let loginHintString, let login = Atmosphere.Account.Identifier(loginHintString) { 133 124 @Dependency(\.oauthAppConfiguration) var oauthAppConfig ··· 141 132 let callbackURL = try await webAuthenticationSession.open(url: pending.authorizationURL, redirectURI: oauthAppConfig.redirectURI.rawValue) 142 133 let session = try await authClient.handleCallback(url: callbackURL, pending: pending.session) 143 134 $session.withLock { $0 = session } 144 - try store.modify { $0.loggedIn = Spawn($0.makeState()) } 145 135 146 136 case .externalBrowser: 147 137 let pending = try await authClient.beginAuthorization(loginHint: login) ··· 163 153 let callbackURL = try await waitForCallback() 164 154 let session = try await authClient.handleCallback(url: .init(callbackURL), pending: pending.session) 165 155 $session.withLock { $0 = session } 166 - try store.modify { $0.loggedIn = Spawn($0.makeState()) } 167 156 } 168 157 } 169 158 } ··· 201 190 202 191 public func body(content: Content) -> some View { 203 192 Group { 204 - if let loggedInStore: StoreOf<LoggedInDestination> = store.spawn(\.loggedIn) { 193 + if let loggedInStore: StoreOf<LoggedInDestination> = store.scope(state: \.loggedIn, action: \.loggedIn) { 205 194 loggedInContent(loggedInStore) 206 195 } else { 207 196 content ··· 242 231 Authenticated(session: session, makeDestinationFeature: makeDestinationFeature) 243 232 } 244 233 } 234 + 235 + public func authenticate<D: FeatureProtocol>( 236 + _ stateKeyPath: WritableKeyPath<State, Authenticated<D>.State>, 237 + _ actionKeyPath: CaseKeyPath<Action, Authenticated<D>.Action>, 238 + with session: Shared<OAuth.Session?>, 239 + @FeatureBuilder<D.State, D.Action> makeDestinationFeature: @escaping @Sendable () -> D 240 + ) -> some FeatureProtocol<State, Action> { 241 + 242 + Scope(state: stateKeyPath, action: actionKeyPath) { 243 + Authenticated(session: session, makeDestinationFeature: makeDestinationFeature) 244 + } 245 + } 245 246 } 246 247 // MARK: - WebAuthenticationSession dependency 247 248 import DependenciesMacros ··· 262 263 set { self[WebAuthenticationSession.self] = newValue } 263 264 } 264 265 } 265 - //// 266 - ////// MARK: - Placeholder session 267 - //// 268 - //////extension OAuth.AuthorizationSession { 269 - ////// fileprivate static let placeholder = OAuth.AuthorizationSession( 270 - ////// accountID: .init(DID("did:plc:placeholder")!), 271 - ////// tokenEndpoint: .init(URL(string: "https://placeholder.example/oauth/token")!), 272 - ////// scope: "atproto", 273 - ////// accessToken: .init(rawValue: "placeholder"), 274 - ////// refreshToken: .init(rawValue: "placeholder"), 275 - ////// dpopKey: OAuth.DPOP.Key() 276 - ////// ) 277 - //////} 278 - ////// 266 + import AuthenticationServices 267 + #if os(macOS) 268 + import AppKit 269 + #else 270 + import UIKit 271 + #endif 272 + 273 + private final class PresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding { 274 + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 275 + #if os(macOS) 276 + NSApplication.shared.keyWindow ?? NSWindow() 277 + #else 278 + UIApplication.shared.connectedScenes 279 + .compactMap { $0 as? UIWindowScene } 280 + .first?.windows.first ?? UIWindow() 281 + #endif 282 + } 283 + } 284 + 285 + extension WebAuthenticationSession: DependencyKey { 286 + public static let liveValue = WebAuthenticationSession( 287 + open: { authURL, redirectURI in 288 + let scheme = redirectURI.scheme ?? "" 289 + return try await withCheckedThrowingContinuation { continuation in 290 + Task { @MainActor in 291 + let provider = PresentationContextProvider() 292 + let session = ASWebAuthenticationSession( 293 + url: authURL, 294 + callbackURLScheme: scheme 295 + ) { callbackURL, error in 296 + _ = provider // keep provider alive until completion 297 + if let error { continuation.resume(throwing: error); return } 298 + guard let callbackURL else { continuation.resume(throwing: URLError(.badURL)); return } 299 + continuation.resume(returning: .init(callbackURL)) 300 + } 301 + session.prefersEphemeralWebBrowserSession = true 302 + session.presentationContextProvider = provider 303 + session.start() 304 + } 305 + } 306 + } 307 + ) 308 + } 309 + 310 + // MARK: - Optional dependency modifier 311 + 312 + private struct OptionalDependencyModifier<State, Action, Value: Sendable & Equatable>: FeatureModifier { 313 + let keyPath: WritableKeyPath<DependencyValues, Value> 314 + let value: Value? 279 315 316 + func _isIdentical(to other: Self) -> Bool { 317 + value == other.value 318 + } 319 + 320 + func body(content: Content) -> some FeatureProtocol<State, Action> { 321 + if let value { 322 + content.dependency(keyPath, value) 323 + } else { 324 + content 325 + } 326 + } 327 + } 328 + 329 + extension FeatureProtocol { 330 + func dependency<V: Sendable & Equatable>( 331 + _ keyPath: WritableKeyPath<DependencyValues, V>, 332 + ifSome value: V? 333 + ) -> some Feature { 334 + self.modifier(OptionalDependencyModifier(keyPath: keyPath, value: value)) 335 + } 336 + } 280 337 281 338 extension OAuth { 282 339 public enum BrowserStrategy: Sendable, Hashable, DependencyKey, TestDependencyKey {
+14 -11
Tests/ATProtoOAuthTests/BeginAuthorizationTests.swift
··· 116 116 ) 117 117 } 118 118 ) 119 + $0.oauthAppConfiguration = OAuth.AppConfiguration(appID: testAppID, redirectURI: testRedirectURI) 120 + $0[OAuth.Client.self] = .liveValue 119 121 } 120 122 ) 121 123 struct BeginAuthorizationTests { 122 124 123 - let client = OAuth.Client.liveValue(appID: testAppID, redirectURI: testRedirectURI) 125 + @Dependency(OAuth.Client.self) var client 126 + // let client = OAuth.Client.liveValue(appID: testAppID, redirectURI: testRedirectURI) 124 127 125 128 @Test("Returns authorization URL with correct query parameters") 126 129 func authorizationURLQueryParams() async throws { ··· 129 132 String(data: parResponseData(), encoding: .utf8)! 130 133 } 131 134 } operation: { 132 - let (url, _) = try await client.beginAuthorization(.handle(testHandle), "atproto") 135 + let (url, _) = try await client.beginAuthorization(.handle(testHandle)) 133 136 let components = URLComponents(url: url, resolvingAgainstBaseURL: false)! 134 137 let queryItems = Dictionary(uniqueKeysWithValues: components.queryItems!.map { ($0.name, $0.value!) }) 135 138 ··· 145 148 String(data: parResponseData(), encoding: .utf8)! 146 149 } 147 150 } operation: { 148 - let (_, pending) = try await client.beginAuthorization(.handle(testHandle), "atproto") 151 + let (_, pending) = try await client.beginAuthorization(.handle(testHandle)) 149 152 150 153 #expect(pending.accountID == testAccount.id) 151 154 #expect(pending.state.rawValue == fixedUUID.uuidString) ··· 174 177 return String(data: parResponseData(), encoding: .utf8)! 175 178 } 176 179 } operation: { 177 - _ = try await client.beginAuthorization(.handle(testHandle), "atproto") 180 + _ = try await client.beginAuthorization(.handle(testHandle)) 178 181 } 179 182 } 180 183 ··· 195 198 $0.httpClient = .testClient { _, _, _ in "" } 196 199 } operation: { 197 200 await #expect(throws: OAuth.Client.AuthorizationError.handleNotClaimedByDIDDocument) { 198 - _ = try await client.beginAuthorization(.handle(testHandle), "atproto") 201 + _ = try await client.beginAuthorization(.handle(testHandle)) 199 202 } 200 203 } 201 204 } ··· 212 215 $0.httpClient = .testClient { _, _, _ in "" } 213 216 } operation: { 214 217 await #expect(throws: OAuth.Client.AuthorizationError.issuerMismatch) { 215 - _ = try await client.beginAuthorization(.handle(testHandle), "atproto") 218 + _ = try await client.beginAuthorization(.handle(testHandle)) 216 219 } 217 220 } 218 221 } ··· 227 230 $0.httpClient = .testClient { _, _, _ in "" } 228 231 } operation: { 229 232 await #expect(throws: OAuth.Client.AuthorizationError.didDocumentMismatch) { 230 - _ = try await client.beginAuthorization(.did(testDID), "atproto") 233 + _ = try await client.beginAuthorization(.did(testDID)) 231 234 } 232 235 } 233 236 } ··· 241 244 $0.httpClient = .testClient { _, _, _ in "" } 242 245 } operation: { 243 246 await #expect(throws: OAuth.Client.AuthorizationError.noPDSHost) { 244 - _ = try await client.beginAuthorization(.handle(testHandle), "atproto") 247 + _ = try await client.beginAuthorization(.handle(testHandle)) 245 248 } 246 249 } 247 250 } ··· 253 256 String(data: parResponseData(), encoding: .utf8)! 254 257 } 255 258 } operation: { 256 - let (url, _) = try await client.beginAuthorization(.handle(testHandle), "atproto") 259 + let (url, _) = try await client.beginAuthorization(.handle(testHandle)) 257 260 #expect(url.host() == "bsky.social") 258 261 #expect(url.path() == "/oauth/authorize") 259 262 } ··· 272 275 try await withDependencies { 273 276 $0.httpClient = .testClient { _, _, _ in String(data: parResponseData(), encoding: .utf8)! } 274 277 } operation: { 275 - let (_, pending) = try await client.beginAuthorization(.handle(testHandle), "atproto") 278 + let (_, pending) = try await client.beginAuthorization(.handle(testHandle)) 276 279 #expect(pending.accountID == testAccount.id) 277 280 } 278 281 ··· 284 287 return String(data: parResponseData(), encoding: .utf8)! 285 288 } 286 289 } operation: { 287 - let (_, pending) = try await client.beginAuthorization(.did(testDID), "atproto") 290 + let (_, pending) = try await client.beginAuthorization(.did(testDID)) 288 291 #expect(pending.accountID == didAccount.id) 289 292 } 290 293 }
+5 -5
Tests/ATProtoOAuthTests/CallbackTests.swift
··· 80 80 } 81 81 } operation: { 82 82 let session = try await client.handleCallback( 83 - url: URL(string: "https://myapp.example.com/callback?code=test-code&state=test-nonce&iss=https://bsky.social")!, 83 + url: .init(URL(string: "https://myapp.example.com/callback?code=test-code&state=test-nonce&iss=https://bsky.social")!), 84 84 pending: pending 85 85 ) 86 86 #expect(session == expectedSession) ··· 90 90 @Test("Throws invalidCallbackURL when code is missing") 91 91 func throwsWhenCodeMissing() async throws { 92 92 let pending = try callbackTestPending() 93 - let url = URL(string: "https://myapp.example.com/callback?state=test-nonce&iss=https://bsky.social")! 93 + let url: OAuth.App.CallbackURI = .init(URL(string: "https://myapp.example.com/callback?state=test-nonce&iss=https://bsky.social")!) 94 94 await #expect(throws: OAuth.Client.AuthorizationError.invalidCallbackURL) { 95 95 try await client.handleCallback(url: url, pending: pending) 96 96 } ··· 99 99 @Test("Throws invalidCallbackURL when state is missing") 100 100 func throwsWhenStateMissing() async throws { 101 101 let pending = try callbackTestPending() 102 - let url = URL(string: "https://myapp.example.com/callback?code=test-code&iss=https://bsky.social")! 102 + let url: OAuth.App.CallbackURI = .init(URL(string: "https://myapp.example.com/callback?code=test-code&iss=https://bsky.social")!) 103 103 await #expect(throws: OAuth.Client.AuthorizationError.invalidCallbackURL) { 104 104 try await client.handleCallback(url: url, pending: pending) 105 105 } ··· 108 108 @Test("Throws invalidCallbackURL when iss is missing") 109 109 func throwsWhenIssMissing() async throws { 110 110 let pending = try callbackTestPending() 111 - let url = URL(string: "https://myapp.example.com/callback?code=test-code&state=test-nonce")! 111 + let url: OAuth.App.CallbackURI = .init(URL(string: "https://myapp.example.com/callback?code=test-code&state=test-nonce")!) 112 112 await #expect(throws: OAuth.Client.AuthorizationError.invalidCallbackURL) { 113 113 try await client.handleCallback(url: url, pending: pending) 114 114 } ··· 117 117 @Test("Throws invalidCallbackURL when iss is not a valid origin URL") 118 118 func throwsWhenIssInvalid() async throws { 119 119 let pending = try callbackTestPending() 120 - let url = URL(string: "https://myapp.example.com/callback?code=test-code&state=test-nonce&iss=not-a-url")! 120 + let url: OAuth.App.CallbackURI = .init(URL(string: "https://myapp.example.com/callback?code=test-code&state=test-nonce&iss=not-a-url")!) 121 121 await #expect(throws: OAuth.Client.AuthorizationError.invalidCallbackURL) { 122 122 try await client.handleCallback(url: url, pending: pending) 123 123 }
+6 -2
Tests/ATProtoOAuthTests/ExchangeCodeTests.swift
··· 90 90 $0.p256Sign = { _, _ in fixedSignature } 91 91 $0.formEncoder = FormURLEncoder() 92 92 $0.jsonDecoder = JSONDecoder() 93 + $0.oauthAppConfiguration = OAuth.AppConfiguration(appID: testAppID, redirectURI: testRedirectURI) 94 + $0[OAuth.Client.self] = .liveValue 93 95 } 94 96 ) 95 97 struct ExchangeCodeTests { 96 98 97 - let client = OAuth.Client.liveValue(appID: testAppID, redirectURI: testRedirectURI) 99 + @Dependency(OAuth.Client.self) var client 98 100 99 101 @Test("Throws when iss does not match pending auth server") 100 102 func throwsWhenIssuerMismatch() async throws { ··· 160 162 $0.p256Sign = { _, _ in fixedSignature } 161 163 $0.formEncoder = FormURLEncoder() 162 164 $0.jsonDecoder = JSONDecoder() 165 + $0.oauthAppConfiguration = OAuth.AppConfiguration(appID: testAppID, redirectURI: testRedirectURI) 166 + $0[OAuth.Client.self] = .liveValue 163 167 } 164 168 ) 165 169 struct RefreshTokenTests { 166 170 167 - let client = OAuth.Client.liveValue(appID: testAppID, redirectURI: testRedirectURI) 171 + @Dependency(OAuth.Client.self) var client 168 172 169 173 func testSession() throws -> OAuth.Session { 170 174 let pending = try testPending()
+7
Tests/TCATProtoAuthenticationTests/ExampleApp.swift
··· 1 + import Testing 2 + 3 + 4 + @Suite 5 + struct TCATProtoAuthenticationTests { 6 + 7 + }
-75
Tests/TCATProtoOAuthTests/ExampleApp.swift
··· 1 - // 2 - // ExampleApp.swift 3 - // tcatproto 4 - // 5 - // Minimal example features to validate WithAuthSession usage in tests. 6 - // 7 - 8 - import ComposableArchitecture2 9 - @testable import TCATProtoOAuth 10 - import ATProtoOAuth 11 - 12 - // MARK: - Home feature (logged-in destination) 13 - 14 - @Feature 15 - struct HomeFeature { 16 - @Dependency(\.defaultSession) var session 17 - 18 - struct State: Equatable { 19 - var accountDID: String = "" 20 - } 21 - 22 - var body: some Feature { 23 - EmptyFeature() 24 - .onMount { state in 25 - state.accountDID = session.accountID.rawValue.description 26 - } 27 - } 28 - } 29 - 30 - // MARK: - Login feature posts BeginLogin to kick off OAuth 31 - 32 - @Feature 33 - struct LoginFeature { 34 - struct State: Equatable { 35 - var handle: String = "" 36 - } 37 - 38 - enum Action { 39 - case loginButtonTapped 40 - } 41 - 42 - var body: some Feature { 43 - Update { state, action in 44 - switch action { 45 - case .loginButtonTapped: 46 - store.addTask { 47 - try store.post(key: BeginLogin.self, value: store.handle) 48 - } 49 - } 50 - } 51 - } 52 - } 53 - 54 - // MARK: - Test app root 55 - 56 - @Feature 57 - struct TestAppRoot { 58 - struct State { 59 - var auth: Authenticated<HomeFeature>.State 60 - 61 - init() { 62 - auth = Authenticated<HomeFeature>.State() 63 - } 64 - } 65 - 66 - enum Action { 67 - case auth(Authenticated<HomeFeature>.Action) 68 - } 69 - 70 - var body: some Feature { 71 - Scope(state: \.auth, action: \.auth) { 72 - Authenticated(makeDestinationState: { _ in HomeFeature.State() }) 73 - } 74 - } 75 - }
+2 -1
Tests/XRPCClientTests/XRPCClientTests.swift
··· 56 56 ) 57 57 struct LiveTests { 58 58 @Test( 59 - .dependency(\.pdsHost, "public.api.bsky.app") 59 + .dependency(\.pdsHost, "public.api.bsky.app"), 60 + .disabled("is bluesky down?") 60 61 ) 61 62 func fetchMyBlueskyPost() async throws { 62 63 // https://bsky.app/profile/woody.fm/post/3mijxhhak2s2l