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 code exchange + refresh token endpoints

+184
+104
Packages/ATProto/Sources/ATProto/OAuth/Flow/TokenExchange.swift
··· 1 + import Foundation 2 + 3 + public struct TokenExchangeResponse: Sendable, Equatable { 4 + public let accessToken: String 5 + public let refreshToken: String? 6 + public let tokenType: String 7 + public let expiresIn: Int 8 + public let scope: String 9 + public let sub: String? 10 + } 11 + 12 + public struct TokenExchange: Sendable { 13 + let endpoint: URL 14 + let session: URLSession 15 + let nonceStore: DPoPNonceStore 16 + 17 + public init(endpoint: URL, session: URLSession = .shared, nonceStore: DPoPNonceStore) { 18 + self.endpoint = endpoint 19 + self.session = session 20 + self.nonceStore = nonceStore 21 + } 22 + 23 + public func exchangeCode( 24 + clientId: String, 25 + redirectURI: String, 26 + code: String, 27 + codeVerifier: String, 28 + dpopKey: any DPoPKey 29 + ) async throws -> TokenExchangeResponse { 30 + let body: [String: String] = [ 31 + "grant_type": "authorization_code", 32 + "client_id": clientId, 33 + "redirect_uri": redirectURI, 34 + "code": code, 35 + "code_verifier": codeVerifier, 36 + ] 37 + return try await post(body: body, dpopKey: dpopKey) 38 + } 39 + 40 + public func refresh( 41 + clientId: String, 42 + refreshToken: String, 43 + dpopKey: any DPoPKey 44 + ) async throws -> TokenExchangeResponse { 45 + let body: [String: String] = [ 46 + "grant_type": "refresh_token", 47 + "client_id": clientId, 48 + "refresh_token": refreshToken, 49 + ] 50 + return try await post(body: body, dpopKey: dpopKey) 51 + } 52 + 53 + private func post(body: [String: String], dpopKey: any DPoPKey) async throws -> TokenExchangeResponse { 54 + let formBody = FormURLEncoded.encode(body) 55 + 56 + for attempt in 0..<2 { 57 + var request = URLRequest(url: endpoint) 58 + request.httpMethod = "POST" 59 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 60 + let nonce = await nonceStore.nonce(for: endpoint) 61 + let dpopProof = try await DPoPProof.create( 62 + method: "POST", 63 + url: endpoint, 64 + key: dpopKey, 65 + nonce: nonce, 66 + accessToken: nil 67 + ) 68 + request.setValue(dpopProof, forHTTPHeaderField: "DPoP") 69 + request.httpBody = formBody 70 + 71 + let (data, response) = try await session.data(for: request) 72 + guard let http = response as? HTTPURLResponse else { 73 + throw ATProtoError.http(status: 0, body: nil) 74 + } 75 + if let fresh = http.value(forHTTPHeaderField: "DPoP-Nonce") { 76 + await nonceStore.setNonce(fresh, for: endpoint) 77 + } 78 + if http.statusCode == 401 && attempt == 0 && http.value(forHTTPHeaderField: "DPoP-Nonce") != nil { 79 + continue 80 + } 81 + guard (200..<300).contains(http.statusCode) else { 82 + throw ATProtoError.http(status: http.statusCode, body: data) 83 + } 84 + struct Raw: Decodable { 85 + let access_token: String 86 + let refresh_token: String? 87 + let token_type: String 88 + let expires_in: Int 89 + let scope: String 90 + let sub: String? 91 + } 92 + let raw = try JSONDecoder().decode(Raw.self, from: data) 93 + return TokenExchangeResponse( 94 + accessToken: raw.access_token, 95 + refreshToken: raw.refresh_token, 96 + tokenType: raw.token_type, 97 + expiresIn: raw.expires_in, 98 + scope: raw.scope, 99 + sub: raw.sub 100 + ) 101 + } 102 + throw ATProtoError.http(status: 401, body: nil) 103 + } 104 + }
+80
Packages/ATProto/Tests/ATProtoTests/OAuth/TokenExchangeTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import ATProto 4 + 5 + @Suite("TokenExchange", .serialized) 6 + struct TokenExchangeTests { 7 + @Test("code exchange returns tokens + scope") 8 + func codeExchange() async throws { 9 + let session = URLProtocolStub.install { request in 10 + #expect(request.httpMethod == "POST") 11 + #expect(request.url?.absoluteString == "https://bsky.social/oauth/token") 12 + #expect(request.value(forHTTPHeaderField: "DPoP") != nil) 13 + return .json(#""" 14 + {"access_token":"access-1","refresh_token":"refresh-1","token_type":"DPoP", 15 + "expires_in":3600,"scope":"atproto","sub":"did:plc:abc"} 16 + """#) 17 + } 18 + defer { URLProtocolStub.reset(session: session) } 19 + 20 + let key = try InMemoryES256DPoPKey.generate() 21 + let exchange = TokenExchange( 22 + endpoint: URL(string: "https://bsky.social/oauth/token")!, 23 + session: session, 24 + nonceStore: DPoPNonceStore() 25 + ) 26 + let tokens = try await exchange.exchangeCode( 27 + clientId: "https://pdfs.at/oauth/client-metadata.json", 28 + redirectURI: "pdfs://oauth/callback", 29 + code: "auth-code", 30 + codeVerifier: "verifier", 31 + dpopKey: key 32 + ) 33 + #expect(tokens.accessToken == "access-1") 34 + #expect(tokens.refreshToken == "refresh-1") 35 + #expect(tokens.scope == "atproto") 36 + #expect(tokens.sub == "did:plc:abc") 37 + #expect(tokens.expiresIn == 3600) 38 + } 39 + 40 + @Test("refresh returns new tokens preserving DID") 41 + func refresh() async throws { 42 + let session = URLProtocolStub.install { request in 43 + let bodyData = request.httpBodyStream.flatMap(readAll) ?? request.httpBody ?? Data() 44 + let bodyString = String(decoding: bodyData, as: UTF8.self) 45 + #expect(bodyString.contains("grant_type=refresh_token")) 46 + return .json(#""" 47 + {"access_token":"access-2","refresh_token":"refresh-2","token_type":"DPoP", 48 + "expires_in":3600,"scope":"atproto","sub":"did:plc:abc"} 49 + """#) 50 + } 51 + defer { URLProtocolStub.reset(session: session) } 52 + 53 + let key = try InMemoryES256DPoPKey.generate() 54 + let exchange = TokenExchange( 55 + endpoint: URL(string: "https://bsky.social/oauth/token")!, 56 + session: session, 57 + nonceStore: DPoPNonceStore() 58 + ) 59 + let tokens = try await exchange.refresh( 60 + clientId: "https://pdfs.at/oauth/client-metadata.json", 61 + refreshToken: "refresh-old", 62 + dpopKey: key 63 + ) 64 + #expect(tokens.accessToken == "access-2") 65 + } 66 + } 67 + 68 + private func readAll(_ stream: InputStream) -> Data { 69 + stream.open() 70 + defer { stream.close() } 71 + var data = Data() 72 + let bufSize = 4096 73 + var buf = [UInt8](repeating: 0, count: bufSize) 74 + while stream.hasBytesAvailable { 75 + let read = stream.read(&buf, maxLength: bufSize) 76 + if read <= 0 { break } 77 + data.append(buf, count: read) 78 + } 79 + return data 80 + }