this repo has no description
2
fork

Configure Feed

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

API hardening

+357 -39
-1
Package.swift
··· 20 20 ], 21 21 dependencies: [ 22 22 .package(url: "https://github.com/ChimeHQ/OAuthenticator.git", branch: "main"), 23 - // .package(path: "../OAuthenticator"), 24 23 .package(url: "https://github.com/vapor/jwt-kit.git", from: "5.0.0"), 25 24 .package(url: "https://github.com/SparrowTek/NetworkingKit.git", branch: "main"), 26 25 ],
+47 -14
Sources/CoreATProtocol/APEnvironment.swift
··· 2 2 // APEnvironment.swift 3 3 // CoreATProtocol 4 4 // 5 - // Created by Thomas Rademaker on 10/10/25. 6 - // 7 5 8 6 import JWTKit 9 7 8 + /// Session-scoped state for a single AT Protocol session. 9 + /// 10 + /// Today this is a process-wide singleton accessed via ``shared``. A future 11 + /// major release will make it instance-based so a single process can host 12 + /// multiple concurrent sessions without the routing layer needing a global 13 + /// handle. Until then, callers should treat ``shared`` as the one-and-only 14 + /// session and avoid reading or mutating its state from outside CoreATProtocol 15 + /// — use the free functions in `CoreATProtocol.swift` (`setup`, `updateTokens`, 16 + /// etc.) as the public API. 10 17 @APActor 11 - public class APEnvironment { 12 - public static var current: APEnvironment = APEnvironment() 13 - 18 + public final class ATProtoSession { 19 + public static let shared = ATProtoSession() 20 + 14 21 public var host: String? 15 22 public var accessToken: String? 16 23 public var refreshToken: String? 17 - public var atProtocoldelegate: CoreATProtocolDelegate? 24 + public var atProtocolDelegate: CoreATProtocolDelegate? 18 25 public var tokenRefreshHandler: (@Sendable () async throws -> Bool)? 19 26 public var dpopPrivateKey: ES256PrivateKey? 20 27 public var dpopKeys: JWTKeyCollection? 21 28 public let dpopNonceStore = DPoPNonceStore() 22 29 public let clockSkewStore = ClockSkewStore() 23 30 public let routerDelegate = APRouterDelegate() 24 - 25 - private init() {} 26 - 27 - // func setup(apiKey: String, apiSecret: String, userAgent: String) { 28 - // self.apiKey = apiKey 29 - // self.apiSecret = apiSecret 30 - // self.userAgent = userAgent 31 - // } 31 + 32 + internal init() {} 33 + 34 + /// Clears all mutable session state. Intended for test harnesses. 35 + /// 36 + /// DPoP nonce and clock-skew stores are reset to empty; tokens, keys, host 37 + /// and delegates are nilled out. The router delegate and its coordinator 38 + /// are preserved (they hold no user-scoped state). 39 + public func reset() async { 40 + host = nil 41 + accessToken = nil 42 + refreshToken = nil 43 + atProtocolDelegate = nil 44 + tokenRefreshHandler = nil 45 + dpopPrivateKey = nil 46 + dpopKeys = nil 47 + await dpopNonceStore.clear() 48 + await clockSkewStore.update(offset: 0) 49 + } 50 + } 51 + 52 + // MARK: - Deprecation shim 53 + // 54 + // The original name was `APEnvironment` and the singleton was `.current`. 55 + // The renames below preserve source compatibility for downstream callers 56 + // (bskyKit, EffemKit, Atprosphere) while emitting fix-its that point at the 57 + // new names. A future major release will remove these aliases. 58 + 59 + @available(*, deprecated, renamed: "ATProtoSession") 60 + public typealias APEnvironment = ATProtoSession 61 + 62 + extension ATProtoSession { 63 + @available(*, deprecated, renamed: "shared") 64 + public static var current: ATProtoSession { shared } 32 65 }
+13 -13
Sources/CoreATProtocol/CoreATProtocol.swift
··· 36 36 37 37 @APActor 38 38 public func setup(hostURL: String?, accessJWT: String?, refreshJWT: String?, delegate: CoreATProtocolDelegate? = nil) { 39 - APEnvironment.current.host = hostURL 40 - APEnvironment.current.accessToken = accessJWT 41 - APEnvironment.current.refreshToken = refreshJWT 42 - APEnvironment.current.atProtocoldelegate = delegate 39 + ATProtoSession.shared.host = hostURL 40 + ATProtoSession.shared.accessToken = accessJWT 41 + ATProtoSession.shared.refreshToken = refreshJWT 42 + ATProtoSession.shared.atProtocolDelegate = delegate 43 43 } 44 44 45 45 @APActor 46 46 public func setDelegate(_ delegate: CoreATProtocolDelegate) { 47 - APEnvironment.current.atProtocoldelegate = delegate 47 + ATProtoSession.shared.atProtocolDelegate = delegate 48 48 } 49 49 50 50 @APActor 51 51 public func setTokenRefreshHandler(_ handler: (@Sendable () async throws -> Bool)?) { 52 - APEnvironment.current.tokenRefreshHandler = handler 52 + ATProtoSession.shared.tokenRefreshHandler = handler 53 53 } 54 54 55 55 @APActor 56 56 public func setDPoPPrivateKey(pem: String?) async throws { 57 57 guard let pem, !pem.isEmpty else { 58 - APEnvironment.current.dpopPrivateKey = nil 59 - APEnvironment.current.dpopKeys = nil 58 + ATProtoSession.shared.dpopPrivateKey = nil 59 + ATProtoSession.shared.dpopKeys = nil 60 60 return 61 61 } 62 62 ··· 64 64 let keys = JWTKeyCollection() 65 65 await keys.add(ecdsa: privateKey) 66 66 67 - APEnvironment.current.dpopPrivateKey = privateKey 68 - APEnvironment.current.dpopKeys = keys 67 + ATProtoSession.shared.dpopPrivateKey = privateKey 68 + ATProtoSession.shared.dpopKeys = keys 69 69 } 70 70 71 71 @APActor 72 72 public func updateTokens(access: String?, refresh: String?) { 73 - APEnvironment.current.accessToken = access 74 - APEnvironment.current.refreshToken = refresh 73 + ATProtoSession.shared.accessToken = access 74 + ATProtoSession.shared.refreshToken = refresh 75 75 } 76 76 77 77 @APActor 78 78 public func update(hostURL: String?) { 79 - APEnvironment.current.host = hostURL 79 + ATProtoSession.shared.host = hostURL 80 80 }
+10 -10
Sources/CoreATProtocol/Networking.swift
··· 44 44 guard lastFetched != 0 else { return true } 45 45 let currentTime = Date.now 46 46 let lastFetchTime = Date(timeIntervalSince1970: lastFetched) 47 - guard let differenceInMinutes = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false } 48 - return differenceInMinutes >= timeLimit 47 + guard let differenceInSeconds = Calendar.current.dateComponents([.second], from: lastFetchTime, to: currentTime).second else { return false } 48 + return differenceInSeconds >= timeLimit 49 49 } 50 50 51 51 @NetworkingKitActor ··· 53 53 private let refreshCoordinator = TokenRefreshCoordinator() 54 54 55 55 public func intercept(_ request: inout URLRequest) async { 56 - guard let accessToken = await APEnvironment.current.accessToken else { return } 56 + guard let accessToken = await ATProtoSession.shared.accessToken else { return } 57 57 58 - if let dpopKey = await APEnvironment.current.dpopPrivateKey, 59 - let keys = await APEnvironment.current.dpopKeys { 58 + if let dpopKey = await ATProtoSession.shared.dpopPrivateKey, 59 + let keys = await ATProtoSession.shared.dpopKeys { 60 60 // DPoP-bound token: use "DPoP" scheme + DPoP proof header 61 61 do { 62 62 let proof = try await generateDPoPProof(for: request, accessToken: accessToken, privateKey: dpopKey, keys: keys) ··· 97 97 98 98 // Read the nonce at proof-generation time so a concurrent update 99 99 // between intercept() and sign() is observed on the next retry. 100 - let nonce = await APEnvironment.current.dpopNonceStore.get() 101 - let issuedAt = await APEnvironment.current.clockSkewStore.serverAdjustedNow() 100 + let nonce = await ATProtoSession.shared.dpopNonceStore.get() 101 + let issuedAt = await ATProtoSession.shared.clockSkewStore.serverAdjustedNow() 102 102 103 103 // ath: base64url-encoded SHA-256 hash of the access token (RFC 9449 §4.2) 104 104 let hash = SHA256.hash(data: Data(accessToken.utf8)) ··· 134 134 // DPoP rejections often correlate with skew, so harvest this eagerly. 135 135 let dateHeader = response.value(forHTTPHeaderField: "Date") 136 136 if let dateHeader { 137 - await APEnvironment.current.clockSkewStore.updateFromServerDate(dateHeader) 137 + await ATProtoSession.shared.clockSkewStore.updateFromServerDate(dateHeader) 138 138 } 139 139 140 140 let headerNonce = response.value(forHTTPHeaderField: "DPoP-Nonce") 141 141 ?? response.value(forHTTPHeaderField: "dpop-nonce") 142 142 if let headerNonce { 143 - await APEnvironment.current.dpopNonceStore.update(headerNonce) 143 + await ATProtoSession.shared.dpopNonceStore.update(headerNonce) 144 144 lastErrorHadNonceHeader = true 145 145 } else { 146 146 lastErrorHadNonceHeader = false ··· 181 181 } 182 182 183 183 private func refreshViaOAuth() async throws -> Bool { 184 - guard let handler = await APEnvironment.current.tokenRefreshHandler else { 184 + guard let handler = await ATProtoSession.shared.tokenRefreshHandler else { 185 185 return false 186 186 } 187 187 return try await refreshCoordinator.refresh(using: handler)
+1 -1
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
··· 507 507 // Strip query params and fragments from htu per DPoP spec 508 508 let htu = stripQueryAndFragment(from: params.requestEndpoint) 509 509 510 - let issuedAt = await APEnvironment.current.clockSkewStore.serverAdjustedNow() 510 + let issuedAt = await ATProtoSession.shared.clockSkewStore.serverAdjustedNow() 511 511 512 512 let payload = DPoPPayload( 513 513 htm: params.httpMethod,
+32
Tests/CoreATProtocolTests/Base64URLTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import CoreATProtocol 4 + 5 + @Suite("Base64URL encoding") 6 + struct Base64URLTests { 7 + @Test("Data encoding strips padding and swaps alphabet") 8 + func dataEncoding() { 9 + // Bytes chosen so the standard base64 output contains both + and /. 10 + let input = Data([0xfb, 0xff, 0xbf, 0xfe]) 11 + #expect(input.base64EncodedString() == "+/+//g==") 12 + #expect(input.base64URLEncodedString() == "-_-__g") 13 + } 14 + 15 + @Test("Empty data round-trips to empty string") 16 + func emptyData() { 17 + #expect(Data().base64URLEncodedString() == "") 18 + } 19 + 20 + @Test("String helper rewrites alphabet and strips padding") 21 + func stringRewrite() { 22 + #expect("+/+//g==".base64URLEncoded() == "-_-__g") 23 + #expect("abcd".base64URLEncoded() == "abcd") 24 + } 25 + 26 + @Test("Variable-length inputs produce unpadded output") 27 + func variableLengthInputs() { 28 + #expect(Data([0x01]).base64URLEncodedString() == "AQ") 29 + #expect(Data([0x01, 0x02]).base64URLEncodedString() == "AQI") 30 + #expect(Data([0x01, 0x02, 0x03]).base64URLEncodedString() == "AQID") 31 + } 32 + }
+83
Tests/CoreATProtocolTests/DPoPStoreTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import CoreATProtocol 4 + 5 + @Suite("ClockSkewStore") 6 + struct ClockSkewStoreTests { 7 + @Test("Parses RFC 1123 Date header and records offset") 8 + func rfc1123() async { 9 + let store = ClockSkewStore() 10 + let local = Date(timeIntervalSince1970: 1_700_000_000) 11 + let serverHeader = "Thu, 14 Nov 2023 22:13:40 GMT" 12 + let serverDate = try! #require(ClockSkewStore.parse(serverHeader)) 13 + 14 + await store.updateFromServerDate(serverHeader, localNow: local) 15 + let offset = await store.offset 16 + #expect(offset == serverDate.timeIntervalSince(local)) 17 + } 18 + 19 + @Test("Ignores nil or unparseable header without mutating state") 20 + func malformedHeader() async { 21 + let store = ClockSkewStore(offset: 123) 22 + await store.updateFromServerDate(nil) 23 + #expect(await store.offset == 123) 24 + 25 + await store.updateFromServerDate("not a date") 26 + #expect(await store.offset == 123) 27 + } 28 + 29 + @Test("serverAdjustedNow applies the stored offset") 30 + func adjustedNow() async { 31 + let store = ClockSkewStore(offset: 42) 32 + let base = Date(timeIntervalSince1970: 1_000_000) 33 + let adjusted = await store.serverAdjustedNow(localNow: base) 34 + #expect(adjusted.timeIntervalSince1970 == 1_000_042) 35 + } 36 + 37 + @Test("Parses the RFC 850 legacy Date format") 38 + func rfc850() { 39 + #expect(ClockSkewStore.parse("Sunday, 06-Nov-94 08:49:37 GMT") != nil) 40 + } 41 + } 42 + 43 + @Suite("DPoPNonceStore") 44 + struct DPoPNonceStoreTests { 45 + @Test("Starts empty") 46 + func initialState() async { 47 + let store = DPoPNonceStore() 48 + #expect(await store.get() == nil) 49 + } 50 + 51 + @Test("Update and read back") 52 + func update() async { 53 + let store = DPoPNonceStore() 54 + await store.update("abc") 55 + #expect(await store.get() == "abc") 56 + await store.update("xyz") 57 + #expect(await store.get() == "xyz") 58 + } 59 + 60 + @Test("Concurrent updates settle on a valid value") 61 + func concurrentUpdates() async { 62 + let store = DPoPNonceStore() 63 + 64 + await withTaskGroup(of: Void.self) { group in 65 + for i in 0..<100 { 66 + group.addTask { await store.update("nonce-\(i)") } 67 + } 68 + } 69 + 70 + let final = await store.get() 71 + let valid = (0..<100).map { "nonce-\($0)" } 72 + #expect(final != nil) 73 + #expect(valid.contains(final ?? "")) 74 + } 75 + 76 + @Test("clear() resets to nil") 77 + func clearResets() async { 78 + let store = DPoPNonceStore(nonce: "start") 79 + #expect(await store.get() == "start") 80 + await store.clear() 81 + #expect(await store.get() == nil) 82 + } 83 + }
+86
Tests/CoreATProtocolTests/IdentityResolverValidationTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import CoreATProtocol 4 + 5 + @Suite("IdentityResolver hostname validation") 6 + struct HostnameValidationTests { 7 + @Test("Valid DNS-style handles are accepted") 8 + func valid() throws { 9 + try IdentityResolver.validateHostname("alice.bsky.social") 10 + try IdentityResolver.validateHostname("example.com") 11 + try IdentityResolver.validateHostname("a-b.c-d.example.com") 12 + } 13 + 14 + @Test("Handles with URL authority characters are rejected", 15 + arguments: [ 16 + "alice@bsky.social", 17 + "alice.bsky.social/extra", 18 + "alice.bsky.social?q=1", 19 + "alice.bsky.social#frag", 20 + "alice.bsky.social:8080", 21 + "alice bsky social", 22 + "aliçe.bsky.social", 23 + ]) 24 + func rejectsInjection(_ input: String) { 25 + #expect(throws: IdentityError.self) { 26 + try IdentityResolver.validateHostname(input) 27 + } 28 + } 29 + 30 + @Test("Empty, single-label, and malformed handles are rejected", 31 + arguments: [ 32 + "", 33 + "com", 34 + ".com", 35 + "alice.", 36 + "alice..com", 37 + "-alice.com", 38 + "alice-.com", 39 + ]) 40 + func rejectsMalformed(_ input: String) { 41 + #expect(throws: IdentityError.self) { 42 + try IdentityResolver.validateHostname(input) 43 + } 44 + } 45 + 46 + @Test("Labels over 63 characters are rejected") 47 + func tooLongLabel() { 48 + let longLabel = String(repeating: "a", count: 64) 49 + #expect(throws: IdentityError.self) { 50 + try IdentityResolver.validateHostname("\(longLabel).com") 51 + } 52 + } 53 + } 54 + 55 + @Suite("DID document PDS lookup") 56 + struct DIDDocumentPDSTests { 57 + @Test("Service matched by #atproto_pds suffix") 58 + func matchBySuffix() { 59 + let doc = DIDDocument( 60 + id: "did:plc:abc", 61 + alsoKnownAs: ["at://alice.bsky.social"], 62 + service: [DIDService(id: "#atproto_pds", type: "Whatever", serviceEndpoint: "https://pds.example")] 63 + ) 64 + #expect(doc.pdsEndpoint == "https://pds.example") 65 + } 66 + 67 + @Test("Service matched by AtprotoPersonalDataServer type") 68 + func matchByType() { 69 + let doc = DIDDocument( 70 + id: "did:plc:abc", 71 + alsoKnownAs: ["at://alice.bsky.social"], 72 + service: [DIDService(id: "#other", type: "AtprotoPersonalDataServer", serviceEndpoint: "https://pds.example")] 73 + ) 74 + #expect(doc.pdsEndpoint == "https://pds.example") 75 + } 76 + 77 + @Test("Document with no matching service returns nil") 78 + func noMatch() { 79 + let doc = DIDDocument( 80 + id: "did:plc:abc", 81 + alsoKnownAs: nil, 82 + service: [DIDService(id: "#other", type: "Unrelated", serviceEndpoint: "https://example.com")] 83 + ) 84 + #expect(doc.pdsEndpoint == nil) 85 + } 86 + }
+85
Tests/CoreATProtocolTests/RefreshLoginTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import CoreATProtocol 4 + 5 + @Suite("ATProtoOAuth refresh error disambiguation") 6 + struct RefreshLoginTests { 7 + private func makeConfig() -> ATProtoOAuthConfig { 8 + ATProtoOAuthConfig( 9 + clientMetadataURL: "https://example.com/client-metadata.json", 10 + redirectURI: "example://callback" 11 + ) 12 + } 13 + 14 + @Test("No stored login returns nil (genuine no-op)") 15 + func noStoredLogin() async throws { 16 + let storage = ATProtoAuthStorage( 17 + retrieveLogin: { nil }, 18 + storeLogin: { _ in }, 19 + retrievePrivateKey: { nil }, 20 + storePrivateKey: { _ in } 21 + ) 22 + let client = await ATProtoOAuth(config: makeConfig(), storage: storage) 23 + let result = try await client.refreshLoginIfNeeded(handle: nil) 24 + #expect(result == nil) 25 + } 26 + 27 + @Test("Missing persisted DPoP key throws .refreshKeyUnavailable") 28 + func missingPersistedKey() async throws { 29 + let expiredLogin = Login( 30 + accessToken: Token(value: "access", expiry: .distantPast), 31 + refreshToken: Token(value: "refresh"), 32 + scopes: "atproto", 33 + issuingServer: "https://bsky.social" 34 + ) 35 + let storage = ATProtoAuthStorage( 36 + retrieveLogin: { expiredLogin }, 37 + storeLogin: { _ in }, 38 + retrievePrivateKey: { nil }, 39 + storePrivateKey: { _ in } 40 + ) 41 + let client = await ATProtoOAuth(config: makeConfig(), storage: storage) 42 + 43 + await #expect(throws: ATProtoOAuthError.self) { 44 + _ = try await client.refreshLoginIfNeeded(handle: nil, force: true) 45 + } 46 + } 47 + 48 + @Test("Expired refresh token throws .refreshTokenUnavailable") 49 + func expiredRefreshToken() async throws { 50 + let expiredLogin = Login( 51 + accessToken: Token(value: "access", expiry: .distantPast), 52 + refreshToken: Token(value: "refresh", expiry: .distantPast), 53 + scopes: "atproto", 54 + issuingServer: "https://bsky.social" 55 + ) 56 + 57 + // Mint a working key via a throwaway client, then feed it back into the 58 + // persisted-key constructor so hasPersistedKey is true and we reach the 59 + // refresh-token-validity check. 60 + let bootstrapStorage = ATProtoAuthStorage( 61 + retrieveLogin: { nil }, 62 + storeLogin: { _ in }, 63 + retrievePrivateKey: { nil }, 64 + storePrivateKey: { _ in } 65 + ) 66 + let bootstrap = await ATProtoOAuth(config: makeConfig(), storage: bootstrapStorage) 67 + let pem = await bootstrap.privateKeyPEM 68 + 69 + let storage = ATProtoAuthStorage( 70 + retrieveLogin: { expiredLogin }, 71 + storeLogin: { _ in }, 72 + retrievePrivateKey: { pem.data(using: .utf8) }, 73 + storePrivateKey: { _ in } 74 + ) 75 + let client = try await ATProtoOAuth( 76 + config: makeConfig(), 77 + storage: storage, 78 + privateKeyPEM: pem 79 + ) 80 + 81 + await #expect(throws: ATProtoOAuthError.self) { 82 + _ = try await client.refreshLoginIfNeeded(handle: nil, force: true) 83 + } 84 + } 85 + }