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 OAuth metadata types + two-step discovery

+182
+19
Packages/ATProto/Sources/ATProto/OAuth/Metadata/AuthorizationServerMetadata.swift
··· 1 + import Foundation 2 + 3 + public struct AuthorizationServerMetadata: Decodable, Sendable, Equatable { 4 + public let issuer: String 5 + public let authorizationEndpoint: URL 6 + public let tokenEndpoint: URL 7 + public let pushedAuthorizationRequestEndpoint: URL? 8 + public let dpopSigningAlgValuesSupported: [String] 9 + public let scopesSupported: [String] 10 + 11 + enum CodingKeys: String, CodingKey { 12 + case issuer 13 + case authorizationEndpoint = "authorization_endpoint" 14 + case tokenEndpoint = "token_endpoint" 15 + case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" 16 + case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported" 17 + case scopesSupported = "scopes_supported" 18 + } 19 + }
+48
Packages/ATProto/Sources/ATProto/OAuth/Metadata/ClientMetadata.swift
··· 1 + import Foundation 2 + 3 + /// The static JSON file this client publishes at 4 + /// `https://pdfs.at/oauth/client-metadata.json`. `clientId` is literally 5 + /// that URL — this is atproto's "client metadata by URL" convention. 6 + public struct ClientMetadata: Codable, Sendable, Equatable { 7 + public let clientId: String 8 + public let clientName: String 9 + public let clientURI: String 10 + public let redirectURIs: [String] 11 + public let grantTypes: [String] 12 + public let responseTypes: [String] 13 + public let scope: String 14 + public let tokenEndpointAuthMethod: String 15 + public let dpopBoundAccessTokens: Bool 16 + public let applicationType: String 17 + 18 + enum CodingKeys: String, CodingKey { 19 + case clientId = "client_id" 20 + case clientName = "client_name" 21 + case clientURI = "client_uri" 22 + case redirectURIs = "redirect_uris" 23 + case grantTypes = "grant_types" 24 + case responseTypes = "response_types" 25 + case scope 26 + case tokenEndpointAuthMethod = "token_endpoint_auth_method" 27 + case dpopBoundAccessTokens = "dpop_bound_access_tokens" 28 + case applicationType = "application_type" 29 + } 30 + 31 + /// The canonical metadata for pdfs. The `scope` field advertises the 32 + /// maximum envelope of scopes we may request over the client's lifetime. 33 + /// We list `repo:*` (wildcard) rather than enumerate every possible 34 + /// collection NSID. Actual authorization requests narrow to specific 35 + /// collections via progressive disclosure — see `OAuthCoordinator.ensureScope`. 36 + public static let defaultPDFS = ClientMetadata( 37 + clientId: "https://pdfs.at/oauth/client-metadata.json", 38 + clientName: "pdfs", 39 + clientURI: "https://pdfs.at", 40 + redirectURIs: ["pdfs://oauth/callback"], 41 + grantTypes: ["authorization_code", "refresh_token"], 42 + responseTypes: ["code"], 43 + scope: "atproto repo:* blob:*/*", 44 + tokenEndpointAuthMethod: "none", 45 + dpopBoundAccessTokens: true, 46 + applicationType: "native" 47 + ) 48 + }
+38
Packages/ATProto/Sources/ATProto/OAuth/Metadata/MetadataResolver.swift
··· 1 + import Foundation 2 + 3 + public struct MetadataResolver: Sendable { 4 + let session: URLSession 5 + 6 + public init(session: URLSession = .shared) { 7 + self.session = session 8 + } 9 + 10 + /// Two-step discovery: 11 + /// 1. GET `<pds>/.well-known/oauth-protected-resource` 12 + /// 2. GET `<authServer>/.well-known/oauth-authorization-server` (using 13 + /// the first authorization_server from step 1) 14 + public func resolve(pds: URL) async throws -> (ProtectedResourceMetadata, AuthorizationServerMetadata) { 15 + let resourceURL = pds.appendingPathComponent(".well-known/oauth-protected-resource") 16 + let resource: ProtectedResourceMetadata = try await fetch(url: resourceURL) 17 + 18 + guard let authServerURL = resource.authorizationServers.first else { 19 + throw ATProtoError.invalidIdentity("no authorization_servers listed for \(pds)") 20 + } 21 + let authMetaURL = authServerURL.appendingPathComponent(".well-known/oauth-authorization-server") 22 + let authMeta: AuthorizationServerMetadata = try await fetch(url: authMetaURL) 23 + return (resource, authMeta) 24 + } 25 + 26 + private func fetch<Value: Decodable>(url: URL) async throws -> Value { 27 + let (data, response) = try await session.data(for: URLRequest(url: url)) 28 + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { 29 + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 30 + throw ATProtoError.invalidIdentity("metadata fetch HTTP \(status) for \(url)") 31 + } 32 + do { 33 + return try JSONDecoder().decode(Value.self, from: data) 34 + } catch { 35 + throw ATProtoError.decoding("\(error)") 36 + } 37 + } 38 + }
+11
Packages/ATProto/Sources/ATProto/OAuth/Metadata/ProtectedResourceMetadata.swift
··· 1 + import Foundation 2 + 3 + public struct ProtectedResourceMetadata: Decodable, Sendable, Equatable { 4 + public let resource: URL 5 + public let authorizationServers: [URL] 6 + 7 + enum CodingKeys: String, CodingKey { 8 + case resource 9 + case authorizationServers = "authorization_servers" 10 + } 11 + }
+66
Packages/ATProto/Tests/ATProtoTests/OAuth/MetadataResolverTests.swift
··· 1 + import Foundation 2 + import Testing 3 + @testable import ATProto 4 + 5 + @Suite("MetadataResolver", .serialized) 6 + struct MetadataResolverTests { 7 + @Test("resolves PDS → resource → authorization server") 8 + func happyPath() async throws { 9 + let session = URLProtocolStub.install { request in 10 + switch request.url?.absoluteString { 11 + case "https://bsky.social/.well-known/oauth-protected-resource": 12 + return .json(#""" 13 + {"resource":"https://bsky.social", 14 + "authorization_servers":["https://bsky.social"]} 15 + """#) 16 + case "https://bsky.social/.well-known/oauth-authorization-server": 17 + return .json(#""" 18 + {"issuer":"https://bsky.social", 19 + "authorization_endpoint":"https://bsky.social/oauth/authorize", 20 + "token_endpoint":"https://bsky.social/oauth/token", 21 + "pushed_authorization_request_endpoint":"https://bsky.social/oauth/par", 22 + "dpop_signing_alg_values_supported":["ES256"], 23 + "scopes_supported":["atproto","repo:*?action=create"]} 24 + """#) 25 + default: 26 + return URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 27 + } 28 + } 29 + defer { URLProtocolStub.reset(session: session) } 30 + 31 + let resolver = MetadataResolver(session: session) 32 + let (resource, authServer) = try await resolver.resolve(pds: URL(string: "https://bsky.social")!) 33 + 34 + #expect(resource.authorizationServers.first?.absoluteString == "https://bsky.social") 35 + #expect(authServer.tokenEndpoint.absoluteString == "https://bsky.social/oauth/token") 36 + #expect(authServer.pushedAuthorizationRequestEndpoint?.absoluteString == "https://bsky.social/oauth/par") 37 + } 38 + 39 + @Test("throws invalidIdentity when PDS doesn't publish protected-resource doc") 40 + func noProtectedResource() async { 41 + let session = URLProtocolStub.install { _ in 42 + URLProtocolStub.Response(statusCode: 404, headers: [:], body: Data()) 43 + } 44 + defer { URLProtocolStub.reset(session: session) } 45 + 46 + let resolver = MetadataResolver(session: session) 47 + await #expect(throws: ATProtoError.self) { 48 + try await resolver.resolve(pds: URL(string: "https://broken.example")!) 49 + } 50 + } 51 + 52 + @Test("ClientMetadata serializes expected JSON") 53 + func clientMetadataJSON() throws { 54 + let meta = ClientMetadata.defaultPDFS 55 + let data = try JSONEncoder().encode(meta) 56 + guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { 57 + Issue.record("expected dictionary") 58 + return 59 + } 60 + #expect((dict["client_id"] as? String) == "https://pdfs.at/oauth/client-metadata.json") 61 + let uris = dict["redirect_uris"] as? [String] 62 + #expect(uris?.contains("pdfs://oauth/callback") == true) 63 + #expect((dict["dpop_bound_access_tokens"] as? Bool) == true) 64 + #expect((dict["application_type"] as? String) == "native") 65 + } 66 + }