mount public data from the atmosphere to a virtual filesystem (macos only) pdfs.at
0
fork

Configure Feed

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

feat(atproto/oauth): add OAuthAuthTokenProvider conforming to AuthTokenProvider

Implements DPoP-bound auth headers, per-origin nonce capture on responses,
and best-effort token refresh on 401 via refresh_token grant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+200
+96
Packages/ATProto/Sources/ATProto/OAuth/OAuthAuthTokenProvider.swift
··· 1 + import Foundation 2 + 3 + public actor OAuthAuthTokenProvider: AuthTokenProvider { 4 + let did: DID 5 + let tokenStore: any TokenStore 6 + let nonceStore: DPoPNonceStore 7 + let tokenEndpoint: URL 8 + let clientId: String 9 + let session: URLSession 10 + 11 + public init( 12 + did: DID, 13 + tokenStore: any TokenStore, 14 + nonceStore: DPoPNonceStore, 15 + tokenEndpoint: URL, 16 + clientId: String, 17 + session: URLSession = .shared 18 + ) { 19 + self.did = did 20 + self.tokenStore = tokenStore 21 + self.nonceStore = nonceStore 22 + self.tokenEndpoint = tokenEndpoint 23 + self.clientId = clientId 24 + self.session = session 25 + } 26 + 27 + public func authHeaders(for request: URLRequest) async throws -> [String: String]? { 28 + guard let tokens = try await tokenStore.load(did: did) else { 29 + throw ATProtoError.notAuthenticated 30 + } 31 + guard let key = try await tokenStore.loadDPoPKey(identifier: tokens.dpopKeyIdentifier) else { 32 + throw ATProtoError.notAuthenticated 33 + } 34 + guard let url = request.url else { 35 + throw ATProtoError.invalidURL("request missing url") 36 + } 37 + let method = request.httpMethod ?? "GET" 38 + let nonce = await nonceStore.nonce(for: url) 39 + let proof = try await DPoPProof.create( 40 + method: method, 41 + url: url, 42 + key: key, 43 + nonce: nonce, 44 + accessToken: tokens.accessToken 45 + ) 46 + return [ 47 + "Authorization": "DPoP \(tokens.accessToken)", 48 + "DPoP": proof, 49 + ] 50 + } 51 + 52 + public func handleResponse(_ response: HTTPURLResponse, for request: URLRequest) async { 53 + if let url = request.url, 54 + let fresh = response.value(forHTTPHeaderField: "DPoP-Nonce") { 55 + await nonceStore.setNonce(fresh, for: url) 56 + } 57 + } 58 + 59 + public func reportTokenRejected() async { 60 + // Attempt a refresh; best-effort. 61 + do { 62 + guard let tokens = try await tokenStore.load(did: did), 63 + let refreshToken = tokens.refreshToken, 64 + let key = try await tokenStore.loadDPoPKey(identifier: tokens.dpopKeyIdentifier) else { 65 + return 66 + } 67 + let exchange = TokenExchange( 68 + endpoint: tokenEndpoint, 69 + session: session, 70 + nonceStore: nonceStore 71 + ) 72 + let fresh = try await exchange.refresh( 73 + clientId: clientId, 74 + refreshToken: refreshToken, 75 + dpopKey: key 76 + ) 77 + let updated = StoredTokens( 78 + did: did, 79 + accessToken: fresh.accessToken, 80 + refreshToken: fresh.refreshToken ?? refreshToken, 81 + expiresAt: Date().addingTimeInterval(TimeInterval(fresh.expiresIn)), 82 + scopes: Scope.parse(fresh.scope), 83 + dpopKeyIdentifier: tokens.dpopKeyIdentifier, 84 + issuer: tokens.issuer 85 + ) 86 + try await tokenStore.save(updated) 87 + } catch { 88 + // Swallow — caller will see the next 401 and we give up. 89 + } 90 + } 91 + 92 + /// The set of scopes currently granted for the stored tokens. 93 + public func grantedScopes() async throws -> Set<Scope> { 94 + (try await tokenStore.load(did: did))?.scopes ?? [] 95 + } 96 + }
+104
Packages/ATProto/Tests/ATProtoTests/OAuth/OAuthAuthTokenProviderTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import ATProto 4 + 5 + @Suite("OAuthAuthTokenProvider", .serialized) 6 + struct OAuthAuthTokenProviderTests { 7 + @Test("returns Authorization + DPoP headers") 8 + func authHeaders() async throws { 9 + let did = DID("did:plc:abc")! 10 + let key = try InMemoryES256DPoPKey.generate() 11 + let store = InMemoryTokenStore() 12 + try await store.saveDPoPKey(key, identifier: "k1") 13 + try await store.save(StoredTokens( 14 + did: did, accessToken: "AT", refreshToken: "RT", 15 + expiresAt: Date().addingTimeInterval(3600), 16 + scopes: [.atproto], dpopKeyIdentifier: "k1", 17 + issuer: "https://bsky.social" 18 + )) 19 + 20 + // Refresh is not invoked by this test — pass a noop TokenExchange using a stub session. 21 + let dummySession = URLProtocolStub.install { _ in 22 + URLProtocolStub.Response(statusCode: 500, headers: [:], body: Data()) 23 + } 24 + defer { URLProtocolStub.reset(session: dummySession) } 25 + 26 + let provider = OAuthAuthTokenProvider( 27 + did: did, 28 + tokenStore: store, 29 + nonceStore: DPoPNonceStore(), 30 + tokenEndpoint: URL(string: "https://bsky.social/oauth/token")!, 31 + clientId: "https://pdfs.at/oauth/client-metadata.json", 32 + session: dummySession 33 + ) 34 + let request = URLRequest(url: URL(string: "https://bsky.social/xrpc/com.atproto.repo.describeRepo")!) 35 + let headers = try await provider.authHeaders(for: request) 36 + #expect(headers?["Authorization"] == "DPoP AT") 37 + #expect(headers?["DPoP"] != nil) 38 + } 39 + 40 + @Test("handleResponse captures DPoP-Nonce for next request") 41 + func nonceUpdate() async throws { 42 + let did = DID("did:plc:abc")! 43 + let key = try InMemoryES256DPoPKey.generate() 44 + let store = InMemoryTokenStore() 45 + try await store.saveDPoPKey(key, identifier: "k1") 46 + try await store.save(StoredTokens( 47 + did: did, accessToken: "AT", refreshToken: "RT", 48 + expiresAt: Date().addingTimeInterval(3600), 49 + scopes: [.atproto], dpopKeyIdentifier: "k1", 50 + issuer: "https://bsky.social" 51 + )) 52 + let nonceStore = DPoPNonceStore() 53 + let session = URLProtocolStub.install { _ in 54 + URLProtocolStub.Response(statusCode: 500, headers: [:], body: Data()) 55 + } 56 + defer { URLProtocolStub.reset(session: session) } 57 + 58 + let provider = OAuthAuthTokenProvider( 59 + did: did, tokenStore: store, nonceStore: nonceStore, 60 + tokenEndpoint: URL(string: "https://bsky.social/oauth/token")!, 61 + clientId: "https://pdfs.at/oauth/client-metadata.json", 62 + session: session 63 + ) 64 + let url = URL(string: "https://bsky.social/xrpc/thing")! 65 + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["DPoP-Nonce": "n1"])! 66 + await provider.handleResponse(response, for: URLRequest(url: url)) 67 + let stored = await nonceStore.nonce(for: url) 68 + #expect(stored == "n1") 69 + } 70 + 71 + @Test("reportTokenRejected refreshes tokens via refresh_token grant") 72 + func tokenRejected() async throws { 73 + let did = DID("did:plc:abc")! 74 + let key = try InMemoryES256DPoPKey.generate() 75 + let store = InMemoryTokenStore() 76 + try await store.saveDPoPKey(key, identifier: "k1") 77 + try await store.save(StoredTokens( 78 + did: did, accessToken: "OLD", refreshToken: "RT", 79 + expiresAt: Date().addingTimeInterval(3600), 80 + scopes: [.atproto], dpopKeyIdentifier: "k1", 81 + issuer: "https://bsky.social" 82 + )) 83 + let session = URLProtocolStub.install { request in 84 + #expect(request.url?.absoluteString == "https://bsky.social/oauth/token") 85 + return .json(#""" 86 + {"access_token":"NEW","refresh_token":"RT2","token_type":"DPoP", 87 + "expires_in":3600,"scope":"atproto","sub":"did:plc:abc"} 88 + """#) 89 + } 90 + defer { URLProtocolStub.reset(session: session) } 91 + 92 + let provider = OAuthAuthTokenProvider( 93 + did: did, tokenStore: store, nonceStore: DPoPNonceStore(), 94 + tokenEndpoint: URL(string: "https://bsky.social/oauth/token")!, 95 + clientId: "https://pdfs.at/oauth/client-metadata.json", 96 + session: session 97 + ) 98 + await provider.reportTokenRejected() 99 + 100 + let loaded = try await store.load(did: did) 101 + #expect(loaded?.accessToken == "NEW") 102 + #expect(loaded?.refreshToken == "RT2") 103 + } 104 + }