ATProto for TCA applications in Swift
0

Configure Feed

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

Route immediate auth outside sheet presentation

authored by

Woodrow Melling and committed by
Tangled
(Jul 10, 2026, 7:27 PM +0300) c078f9cf 266f3c6e

+141 -8
+2
Sources/ATProto/DID.swift
··· 92 92 } 93 93 } 94 94 95 + 96 + 95 97 import DependenciesMacros 96 98 import Dependencies 97 99
+36 -8
Sources/TCATProtoAuthentication/OAuthAppAuthentication.swift
··· 159 159 } 160 160 161 161 public var authorization: AuthorizationFlow.State? 162 + public var directAuthorization: AuthorizationFlow.State? 162 163 163 164 @Shared public var authFailureMessage: ErrorMessage? 164 165 @Shared public var session: OAuth.Session? ··· 172 173 case authorization(AuthorizationFlow.Action) 173 174 case authorizationPresentationRequested(AuthorizationPresentationRequest) 174 175 case authorizationRequestChanged(AuthorizationRequestCenter.Request?) 176 + case directAuthorization(AuthorizationFlow.Action) 175 177 case handleOpenURL(OAuth.App.CallbackURI) 176 178 case resetAuthentication 177 179 } ··· 185 187 186 188 Update { state, action in 187 189 switch action { 188 - case .authorization: 190 + case .authorization, .directAuthorization: 189 191 break 190 192 191 193 case .authorizationPresentationRequested(let request): 192 194 state.routedAuthorizationRequest = request 193 - state.authorization = OAuthAuthenticationCoordinator.authorizationFlowState( 195 + let authorization = OAuthAuthenticationCoordinator.authorizationFlowState( 194 196 for: request, 195 197 session: state.session, 196 198 sessionStore: state.sessionStore 197 199 ) 198 200 if request.startsImmediately { 201 + state.authorization = nil 202 + state.directAuthorization = authorization 199 203 store.addTask { 200 - try store.send(.authorization(.submit)) 204 + try store.send(.directAuthorization(.submit)) 201 205 } 206 + } else { 207 + state.authorization = authorization 208 + state.directAuthorization = nil 202 209 } 203 210 204 211 case .authorizationRequestChanged(.some(let request)): 205 212 state.routedAuthorizationRequest = nil 206 - state.authorization = OAuthAuthenticationCoordinator.authorizationFlowState( 213 + let authorization = OAuthAuthenticationCoordinator.authorizationFlowState( 207 214 for: AuthorizationPresentationRequest( 208 215 loginHint: request.loginHint, 209 216 mode: request.mode, ··· 216 223 sessionStore: state.sessionStore 217 224 ) 218 225 if request.startsImmediately { 226 + state.authorization = nil 227 + state.directAuthorization = authorization 219 228 store.addTask { 220 - try store.send(.authorization(.submit)) 229 + try store.send(.directAuthorization(.submit)) 221 230 } 231 + } else { 232 + state.authorization = authorization 233 + state.directAuthorization = nil 222 234 } 223 235 224 236 case .authorizationRequestChanged(nil): 225 237 state.routedAuthorizationRequest = nil 226 238 state.authorization = nil 239 + state.directAuthorization = nil 227 240 228 241 case .handleOpenURL(let url): 229 - guard state.authorization != nil else { return } 230 242 guard browserStrategy == .externalBrowser else { return } 231 - store.addTask { 232 - try store.send(.authorization(.onOpenCallbackURL(url))) 243 + if state.authorization != nil { 244 + store.addTask { 245 + try store.send(.authorization(.onOpenCallbackURL(url))) 246 + } 247 + } else if state.directAuthorization != nil { 248 + store.addTask { 249 + try store.send(.directAuthorization(.onOpenCallbackURL(url))) 250 + } 233 251 } 234 252 235 253 case .resetAuthentication: 236 254 state.authorization = nil 255 + state.directAuthorization = nil 237 256 let routedRequest = state.routedAuthorizationRequest 238 257 state.routedAuthorizationRequest = nil 239 258 state.$sessionStore.withLock { $0 = OAuth.SessionStore() } ··· 268 287 .ifLet(\.authorization, action: \.authorization) { 269 288 AuthorizationFlow(sessionStore: store.$sessionStore) 270 289 } 290 + .ifLet(\.directAuthorization, action: \.directAuthorization) { 291 + AuthorizationFlow( 292 + sessionStore: store.$sessionStore, 293 + selectedSessionID: store.$selectedSessionID 294 + ) 295 + } 271 296 .onEvent(AuthorizationFlowCompleted.self) { completion, state in 272 297 handleAuthorizationCompletion(completion, state: &state) 273 298 } ··· 284 309 let sessionStore = state.$sessionStore 285 310 state.routedAuthorizationRequest = nil 286 311 state.authorization = nil 312 + state.directAuthorization = nil 287 313 store.addTask { 288 314 await saveAuthenticationStorage( 289 315 authFailureMessage: authFailureMessage, ··· 300 326 let routedRequest = state.routedAuthorizationRequest 301 327 state.routedAuthorizationRequest = nil 302 328 state.authorization = nil 329 + state.directAuthorization = nil 303 330 store.addTask { 304 331 if let routedRequest { 305 332 await routedRequest.responder.cancel() ··· 312 339 let routedRequest = state.routedAuthorizationRequest 313 340 state.routedAuthorizationRequest = nil 314 341 state.authorization = nil 342 + state.directAuthorization = nil 315 343 store.addTask { 316 344 if let routedRequest { 317 345 await routedRequest.responder.fail(error)
+8
Sources/TCATProtoAuthentication/TCATProtoAuth.swift
··· 426 426 session: Shared<OAuth.Session?>, 427 427 authFailureMessage: Shared<ErrorMessage?> 428 428 ) -> ErrorInterceptor { 429 + tokenRefresh(session: { session }, authFailureMessage: authFailureMessage) 430 + } 431 + 432 + public static func tokenRefresh( 433 + session: @escaping @Sendable () -> Shared<OAuth.Session?>, 434 + authFailureMessage: Shared<ErrorMessage?> 435 + ) -> ErrorInterceptor { 429 436 ErrorInterceptor { failedRequest, failureCode, _, failureBody, retry in 430 437 @Dependency(\.jsonDecoder) var jsonDecoder 431 438 let error = try failureBody.map { try jsonDecoder.decode(OAuth.DPOP.ErrorBody.self, from: $0).error } ··· 435 442 else { return nil } 436 443 437 444 @Dependency(OAuth.Client.self) var client 445 + let session = session() 438 446 do { 439 447 let refreshed = try await client.refreshToken(session.wrappedValue ?? .placeholder) 440 448 session.withLock {
+95
Tests/TCATProtoAuthenticationTests/OAuthAppAuthenticationTests.swift
··· 296 296 #expect(store.routedAuthorizationRequest?.id == request.id) 297 297 } 298 298 299 + @Test("Immediate authorization request uses direct flow instead of sheet") 300 + func immediateAuthorizationRequestUsesDirectFlowInsteadOfSheet() async { 301 + var client = OAuth.Client() 302 + client.beginAuthorizationWithPermissions = { _, _ in 303 + try await Task.sleep(for: .seconds(60)) 304 + throw CancellationError() 305 + } 306 + let requirement = testRequirement 307 + let storage = OAuthSessionStorage.localTestStorage() 308 + let store = withDependencies { 309 + $0[OAuth.Client.self] = client 310 + } operation: { 311 + Store( 312 + initialState: OAuthAuthenticationCoordinator.State(storage: storage) 313 + ) { 314 + OAuthAuthenticationCoordinator() 315 + } 316 + } 317 + 318 + let task = store.send( 319 + .authorizationRequestChanged( 320 + AuthorizationRequestCenter.Request( 321 + id: UUID(0), 322 + loginHint: "woody.fm", 323 + startsImmediately: true, 324 + requirement: requirement 325 + ) 326 + ) 327 + ) 328 + 329 + #expect(store.authorization == nil) 330 + #expect(store.directAuthorization?.requirement == requirement) 331 + #expect(store.directAuthorization?.loginHint == "woody.fm") 332 + 333 + task?.cancel() 334 + await task?.value 335 + } 336 + 299 337 @Test("Web authentication session ignores app open URL callback") 300 338 func webAuthenticationSessionIgnoresAppOpenURLCallback() async throws { 301 339 var authorization = AuthorizationFlow.State(requirement: testRequirement) ··· 323 361 324 362 #expect(store.authorization?.isLoading == false) 325 363 #expect(store.authorization?.pendingSession != nil) 364 + } 365 + 366 + @Test("External browser callback routes to direct authorization flow") 367 + func externalBrowserCallbackRoutesToDirectAuthorizationFlow() async throws { 368 + let recorder = AuthorizationRequestRecorder() 369 + let storage = OAuthSessionStorage.localTestStorage() 370 + var directAuthorization = AuthorizationFlow.State( 371 + requirement: testRequirement, 372 + loginHint: "woody.fm" 373 + ) 374 + directAuthorization.pendingSession = try testPendingSession() 375 + var state = OAuthAuthenticationCoordinator.State(storage: storage) 376 + state.directAuthorization = directAuthorization 377 + let store = withDependencies { 378 + $0[OAuth.Client.self] = successfulOAuthClient() 379 + $0.oauthAuthorizationRequests = .recording(recorder) 380 + $0.oauthBrowserStrategy = .externalBrowser 381 + } operation: { 382 + Store(initialState: state) { 383 + OAuthAuthenticationCoordinator() 384 + } 385 + } 386 + 387 + let task = store.send(.handleOpenURL(testCallbackURL)) 388 + await task?.value 389 + try await waitForCondition { 390 + store.directAuthorization == nil 391 + } 392 + 393 + #expect(store.authorization == nil) 394 + #expect(store.directAuthorization == nil) 395 + #expect(store.session == .placeholder) 396 + try await waitForAsyncCondition { 397 + await recorder.events == [.succeed] 398 + } 399 + #expect(await recorder.events == [.succeed]) 326 400 } 327 401 328 402 @Test("Success stores selected session and completes shared request") ··· 732 806 private let testCallbackURL = OAuth.App.CallbackURI( 733 807 URL(string: "app.example:/oauth/callback?code=test-code&state=test-nonce&iss=https://bsky.social")! 734 808 ) 809 + 810 + @MainActor 811 + private func waitForCondition( 812 + _ condition: @escaping @MainActor () -> Bool 813 + ) async throws { 814 + for _ in 0..<100 { 815 + if condition() { return } 816 + try await Task.sleep(for: .milliseconds(10)) 817 + } 818 + try #require(condition()) 819 + } 820 + 821 + private func waitForAsyncCondition( 822 + _ condition: @escaping () async -> Bool 823 + ) async throws { 824 + for _ in 0..<100 { 825 + if await condition() { return } 826 + try await Task.sleep(for: .milliseconds(10)) 827 + } 828 + try #require(await condition()) 829 + } 735 830 736 831 private func successfulOAuthClient() -> OAuth.Client { 737 832 var client = OAuth.Client()