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 PAR + authorize URL + callback parser

+240
+23
Packages/ATProto/Sources/ATProto/OAuth/Flow/AuthorizationURL.swift
··· 1 + import Foundation 2 + 3 + public enum AuthorizationURL { 4 + /// Builds the authorize URL using the PAR-returned `request_uri`. 5 + /// Per RFC 9126, the authorize URL needs only `client_id` + `request_uri`. 6 + public static func build( 7 + authorizeEndpoint: URL, 8 + clientId: String, 9 + requestURI: String 10 + ) throws -> URL { 11 + guard var comps = URLComponents(url: authorizeEndpoint, resolvingAgainstBaseURL: false) else { 12 + throw ATProtoError.invalidURL(authorizeEndpoint.absoluteString) 13 + } 14 + comps.queryItems = [ 15 + URLQueryItem(name: "client_id", value: clientId), 16 + URLQueryItem(name: "request_uri", value: requestURI), 17 + ] 18 + guard let url = comps.url else { 19 + throw ATProtoError.invalidURL(authorizeEndpoint.absoluteString) 20 + } 21 + return url 22 + } 23 + }
+26
Packages/ATProto/Sources/ATProto/OAuth/Flow/CallbackURL.swift
··· 1 + import Foundation 2 + 3 + public struct CallbackURL: Sendable, Equatable { 4 + public let code: String 5 + public let state: String 6 + public let issuer: String? 7 + 8 + public static func parse(url: URL) throws -> CallbackURL { 9 + guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false), 10 + let items = comps.queryItems else { 11 + throw ATProtoError.invalidURL(url.absoluteString) 12 + } 13 + var params: [String: String] = [:] 14 + for item in items { 15 + if let v = item.value { params[item.name] = v } 16 + } 17 + if let error = params["error"] { 18 + let desc = params["error_description"].map { ": \($0)" } ?? "" 19 + throw ATProtoError.xrpc(code: error, message: "OAuth error\(desc)", status: 400) 20 + } 21 + guard let code = params["code"], let state = params["state"] else { 22 + throw ATProtoError.invalidURL("callback missing code/state: \(url)") 23 + } 24 + return CallbackURL(code: code, state: state, issuer: params["iss"]) 25 + } 26 + }
+91
Packages/ATProto/Sources/ATProto/OAuth/Flow/PushedAuthorizationRequest.swift
··· 1 + import Foundation 2 + 3 + public struct PushedAuthorizationRequest: Sendable { 4 + public struct Response: Sendable, Equatable { 5 + public let requestURI: String 6 + public let expiresIn: Int? 7 + } 8 + 9 + let endpoint: URL 10 + let session: URLSession 11 + let nonceStore: DPoPNonceStore 12 + 13 + public init(endpoint: URL, session: URLSession = .shared, nonceStore: DPoPNonceStore) { 14 + self.endpoint = endpoint 15 + self.session = session 16 + self.nonceStore = nonceStore 17 + } 18 + 19 + public func submit( 20 + clientId: String, 21 + redirectURI: String, 22 + scopes: Set<Scope>, 23 + state: String, 24 + pkceChallenge: String, 25 + loginHint: String?, 26 + dpopKey: any DPoPKey 27 + ) async throws -> Response { 28 + var body: [String: String] = [ 29 + "client_id": clientId, 30 + "redirect_uri": redirectURI, 31 + "response_type": "code", 32 + "scope": Scope.formatted(scopes), 33 + "state": state, 34 + "code_challenge": pkceChallenge, 35 + "code_challenge_method": "S256", 36 + ] 37 + if let loginHint { body["login_hint"] = loginHint } 38 + 39 + let (data, http) = try await postWithDPoP(body: body, key: dpopKey) 40 + guard (200..<300).contains(http.statusCode) else { 41 + throw ATProtoError.http(status: http.statusCode, body: data) 42 + } 43 + struct ParResp: Decodable { 44 + let request_uri: String 45 + let expires_in: Int? 46 + } 47 + let decoded = try JSONDecoder().decode(ParResp.self, from: data) 48 + return Response(requestURI: decoded.request_uri, expiresIn: decoded.expires_in) 49 + } 50 + 51 + /// Performs a DPoP-authenticated POST with one nonce retry on 401. 52 + private func postWithDPoP( 53 + body: [String: String], 54 + key: any DPoPKey 55 + ) async throws -> (Data, HTTPURLResponse) { 56 + let formBody = body.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" } 57 + .joined(separator: "&") 58 + .data(using: .utf8)! 59 + 60 + for attempt in 0..<2 { 61 + var request = URLRequest(url: endpoint) 62 + request.httpMethod = "POST" 63 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 64 + let nonce = await nonceStore.nonce(for: endpoint) 65 + let dpopProof = try await DPoPProof.create( 66 + method: "POST", 67 + url: endpoint, 68 + key: key, 69 + nonce: nonce, 70 + accessToken: nil 71 + ) 72 + request.setValue(dpopProof, forHTTPHeaderField: "DPoP") 73 + request.httpBody = formBody 74 + 75 + let (data, response) = try await session.data(for: request) 76 + guard let http = response as? HTTPURLResponse else { 77 + throw ATProtoError.http(status: 0, body: nil) 78 + } 79 + // Pick up any fresh nonce for next call. 80 + if let newNonce = http.value(forHTTPHeaderField: "DPoP-Nonce") { 81 + await nonceStore.setNonce(newNonce, for: endpoint) 82 + } 83 + if http.statusCode == 401 && attempt == 0 && http.value(forHTTPHeaderField: "DPoP-Nonce") != nil { 84 + // Retry with the fresh nonce. 85 + continue 86 + } 87 + return (data, http) 88 + } 89 + throw ATProtoError.http(status: 401, body: nil) 90 + } 91 + }
+27
Packages/ATProto/Tests/ATProtoTests/OAuth/CallbackURLTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import ATProto 4 + 5 + @Suite("CallbackURL") 6 + struct CallbackURLTests { 7 + @Test("parses successful callback") 8 + func success() throws { 9 + let url = URL(string: "pdfs://oauth/callback?code=abc&state=xyz&iss=https%3A%2F%2Fbsky.social")! 10 + let parsed = try CallbackURL.parse(url: url) 11 + #expect(parsed.code == "abc") 12 + #expect(parsed.state == "xyz") 13 + #expect(parsed.issuer == "https://bsky.social") 14 + } 15 + 16 + @Test("throws when code missing") 17 + func missingCode() { 18 + let url = URL(string: "pdfs://oauth/callback?state=xyz")! 19 + #expect(throws: ATProtoError.self) { try CallbackURL.parse(url: url) } 20 + } 21 + 22 + @Test("throws on error response") 23 + func errorResponse() { 24 + let url = URL(string: "pdfs://oauth/callback?error=access_denied&error_description=user+cancelled&state=xyz")! 25 + #expect(throws: ATProtoError.self) { try CallbackURL.parse(url: url) } 26 + } 27 + }
+73
Packages/ATProto/Tests/ATProtoTests/OAuth/PARTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import ATProto 4 + 5 + @Suite("PushedAuthorizationRequest", .serialized) 6 + struct PARTests { 7 + @Test("posts form-encoded body with DPoP + returns request_uri") 8 + func happyPath() async throws { 9 + let session = URLProtocolStub.install { request in 10 + #expect(request.httpMethod == "POST") 11 + #expect(request.url?.absoluteString == "https://bsky.social/oauth/par") 12 + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/x-www-form-urlencoded") 13 + #expect(request.value(forHTTPHeaderField: "DPoP") != nil) 14 + return .json( 15 + #"{"request_uri":"urn:ietf:params:oauth:request_uri:abc123","expires_in":90}"# 16 + ) 17 + } 18 + defer { URLProtocolStub.reset(session: session) } 19 + 20 + let key = try InMemoryES256DPoPKey.generate() 21 + let par = PushedAuthorizationRequest( 22 + endpoint: URL(string: "https://bsky.social/oauth/par")!, 23 + session: session, 24 + nonceStore: DPoPNonceStore() 25 + ) 26 + let response = try await par.submit( 27 + clientId: "https://pdfs.at/oauth/client-metadata.json", 28 + redirectURI: "pdfs://oauth/callback", 29 + scopes: [.atproto], 30 + state: "state-xyz", 31 + pkceChallenge: "abc", 32 + loginHint: "natemoo.re", 33 + dpopKey: key 34 + ) 35 + #expect(response.requestURI == "urn:ietf:params:oauth:request_uri:abc123") 36 + #expect(response.expiresIn == 90) 37 + } 38 + 39 + @Test("retries once on 401 with DPoP-Nonce challenge") 40 + func dpopNonceRetry() async throws { 41 + actor Hit { var count = 0; func inc() -> Int { count += 1; return count } } 42 + let hit = Hit() 43 + let session = URLProtocolStub.install { request in 44 + Task { _ = await hit.inc() } 45 + // Both calls return 401 with DPoP-Nonce; after two we give up. 46 + return URLProtocolStub.Response(statusCode: 401, headers: ["DPoP-Nonce": "fresh-nonce"], body: Data()) 47 + } 48 + defer { URLProtocolStub.reset(session: session) } 49 + 50 + let key = try InMemoryES256DPoPKey.generate() 51 + let par = PushedAuthorizationRequest( 52 + endpoint: URL(string: "https://bsky.social/oauth/par")!, 53 + session: session, 54 + nonceStore: DPoPNonceStore() 55 + ) 56 + do { 57 + _ = try await par.submit( 58 + clientId: "https://pdfs.at/oauth/client-metadata.json", 59 + redirectURI: "pdfs://oauth/callback", 60 + scopes: [.atproto], 61 + state: "s", 62 + pkceChallenge: "c", 63 + loginHint: nil, 64 + dpopKey: key 65 + ) 66 + Issue.record("expected throw after second 401") 67 + } catch { 68 + // After two 401s in a row we give up. 69 + } 70 + let count = await hit.count 71 + #expect(count == 2) 72 + } 73 + }