From 6dd2ea7c34931becc61e5da23c948ce317266c69 Mon Sep 17 00:00:00 2001 From: Thomas Rademaker Date: Sat, 07 Feb 2026 22:57:56 +0000 Subject: [PATCH] stability --- Sources/CoreATProtocol/Networking.swift | 50 ++++++++++++++++++++++++++++++++++++++------------ Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift | 323 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- Sources/CoreATProtocol/OAuth/IdentityResolver.swift | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------- 3 file(s) changed, 494 insertion(s)(+), 43 deletion(s)(-) diff --git a/Sources/CoreATProtocol/Networking.swift b/Sources/CoreATProtocol/Networking.swift --- a/Sources/CoreATProtocol/Networking.swift +++ b/Sources/CoreATProtocol/Networking.swift @@ -9,14 +9,25 @@ extension JSONDecoder { public static var atDecoder: JSONDecoder { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSX" - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) - dateFormatter.locale = Locale(identifier: "en_US") - let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .formatted(dateFormatter) + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + + for formatter in DateParser.formatters { + if let date = formatter.date(from: dateString) { + return date + } + } + + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid atproto datetime: \(dateString)" + ) + ) + } return decoder } @@ -32,18 +43,13 @@ @APActor public class APRouterDelegate: NetworkRouterDelegate { - private var shouldRefreshToken = false private var refreshTask: Task? public func intercept(_ request: inout URLRequest) async { if APEnvironment.current.dpopPrivateKey != nil { return } - - if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken { - shouldRefreshToken = false - request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") - } else if let accessToken = APEnvironment.current.accessToken { + if let accessToken = APEnvironment.current.accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } } @@ -82,4 +88,24 @@ return false } +} + +private enum DateParser { + static let formatters: [DateFormatter] = { + let formats = [ + "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXXXX", + "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX", + "yyyy-MM-dd'T'HH:mm:ssXXXXX", + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", + "yyyy-MM-dd'T'HH:mm:ss'Z'", + ] + + return formats.map { format in + let formatter = DateFormatter() + formatter.dateFormat = format + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + } + }() } diff --git a/Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift b/Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift --- a/Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift +++ b/Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift @@ -62,6 +62,12 @@ case authenticationFailed(String) case identityResolutionFailed case privateKeyExportFailed + case missingRequiredScope(String) + case subjectMismatch(expected: String, actual: String) + case issuerMismatch(expected: String, actual: String) + case malformedAuthorizationCallback + case tokenRequestFailed(String) + case invalidTokenResponse } /// Type alias for the user authenticator callback @@ -124,10 +130,22 @@ handle: String, userAuthenticator: @escaping UserAuthenticator ) async throws -> ATProtoAuthResult { + try await authenticate(identifier: handle, userAuthenticator: userAuthenticator) + } + + /// Authenticate user by handle or DID. + /// - Parameters: + /// - identifier: The user's handle or DID. + /// - userAuthenticator: Callback to present the authorization URL and return the callback URL. + /// - Returns: Authentication result with tokens and user info. + public func authenticate( + identifier: String, + userAuthenticator: @escaping UserAuthenticator + ) async throws -> ATProtoAuthResult { // Step 1: Resolve identity let identity: IdentityResolver.ResolvedIdentity do { - identity = try await identityResolver.resolve(handle: handle) + identity = try await identityResolver.resolve(identifier: identifier) } catch { throw ATProtoOAuthError.authenticationFailed("Identity resolution failed: \(error.localizedDescription)") } @@ -158,6 +176,15 @@ throw ATProtoOAuthError.authenticationFailed("Failed to load server metadata from \(identity.authServerHost): \(error.localizedDescription)") } + try Self.validateIssuer(serverConfig.issuer, matches: identity.authorizationServer) + let isIssuerValidForPDS = try await identityResolver.isAuthorizationServer( + serverConfig.issuer, + validFor: identity.pdsEndpoint + ) + guard isIssuerValidForPDS else { + throw ATProtoOAuthError.authenticationFailed("Authorization server issuer is not valid for resolved PDS.") + } + // Step 5: Create login storage let loginStorage = LoginStorage( retrieveLogin: storage.retrieveLogin, @@ -170,10 +197,12 @@ } // Step 7: Create authenticator - let tokenHandling = ATProto.tokenHandling( - account: handle, + let tokenHandling = buildTokenHandling( + accountHint: identifier, server: serverConfig, - jwtGenerator: jwtGenerator + jwtGenerator: jwtGenerator, + expectedSubjectDID: identity.did, + expectedAuthorizationServer: identity.authorizationServer ) let authenticatorConfig = Authenticator.Configuration( @@ -214,7 +243,7 @@ return ATProtoAuthResult( did: identity.did, - handle: identity.handle, + handle: identity.handle ?? identifier, accessToken: login.accessToken.value, refreshToken: login.refreshToken?.value, expiresIn: Int(login.accessToken.expiry?.timeIntervalSinceNow ?? 3600), @@ -224,6 +253,11 @@ /// Refresh tokens if the stored access token is expired (or if forced). public func refreshLoginIfNeeded(handle: String? = nil, force: Bool = false) async throws -> Login? { + try await refreshLoginIfNeeded(accountIdentifier: handle, force: force) + } + + /// Refresh tokens if the stored access token is expired (or if forced). + public func refreshLoginIfNeeded(accountIdentifier: String? = nil, force: Bool = false) async throws -> Login? { guard let login = try await storage.retrieveLogin() else { return nil } @@ -240,11 +274,17 @@ return nil } + let resolvedIdentity: IdentityResolver.ResolvedIdentity? + if let accountIdentifier { + resolvedIdentity = try await identityResolver.resolve(identifier: accountIdentifier) + } else { + resolvedIdentity = nil + } + let issuer: String if let issuingServer = login.issuingServer { issuer = issuingServer - } else if let handle { - let identity = try await identityResolver.resolve(handle: handle) + } else if let identity = resolvedIdentity { issuer = identity.authorizationServer } else { return nil @@ -257,10 +297,23 @@ let jwtGenerator: DPoPSigner.JWTGenerator = { [self] params in try await self.generateJWT(params: params) } - let tokenHandling = ATProto.tokenHandling( - account: handle, + if let identity = resolvedIdentity { + try Self.validateIssuer(issuer, matches: identity.authorizationServer) + let isIssuerValidForPDS = try await identityResolver.isAuthorizationServer( + issuer, + validFor: identity.pdsEndpoint + ) + guard isIssuerValidForPDS else { + throw ATProtoOAuthError.authenticationFailed("Authorization server issuer is not valid for resolved PDS.") + } + } + + let tokenHandling = buildTokenHandling( + accountHint: accountIdentifier, server: serverConfig, - jwtGenerator: jwtGenerator + jwtGenerator: jwtGenerator, + expectedSubjectDID: resolvedIdentity?.did, + expectedAuthorizationServer: resolvedIdentity?.authorizationServer ?? issuer ) guard let refreshProvider = tokenHandling.refreshProvider else { @@ -383,6 +436,195 @@ return false } } + + private func buildTokenHandling( + accountHint: String?, + server: ServerMetadata, + jwtGenerator: @escaping DPoPSigner.JWTGenerator, + expectedSubjectDID: String?, + expectedAuthorizationServer: String + ) -> TokenHandling { + TokenHandling( + parConfiguration: PARConfiguration( + url: URL(string: server.pushedAuthorizationRequestEndpoint)!, + parameters: { if let accountHint { ["login_hint": accountHint] } else { [:] } }() + ), + authorizationURLProvider: authorizationURLProvider(server: server), + loginProvider: loginProvider( + server: server, + expectedSubjectDID: expectedSubjectDID, + expectedAuthorizationServer: expectedAuthorizationServer + ), + refreshProvider: refreshProvider( + server: server, + expectedSubjectDID: expectedSubjectDID + ), + dpopJWTGenerator: jwtGenerator, + pkce: PKCEVerifier() + ) + } + + private func authorizationURLProvider(server: ServerMetadata) -> TokenHandling.AuthorizationURLProvider { + { params in + guard let parRequestURI = params.parRequestURI else { + throw AuthenticatorError.parRequestURIMissing + } + + var components = URLComponents(string: server.authorizationEndpoint) + components?.queryItems = [ + URLQueryItem(name: "request_uri", value: parRequestURI), + URLQueryItem(name: "client_id", value: params.credentials.clientId), + ] + + guard let url = components?.url else { + throw AuthenticatorError.missingAuthorizationURL + } + return url + } + } + + private func loginProvider( + server: ServerMetadata, + expectedSubjectDID: String?, + expectedAuthorizationServer: String + ) -> TokenHandling.LoginProvider { + { params in + guard let redirectComponents = URLComponents(url: params.redirectURL, resolvingAgainstBaseURL: false) else { + throw ATProtoOAuthError.malformedAuthorizationCallback + } + + guard + let authCode = redirectComponents.queryItems?.first(where: { $0.name == "code" })?.value, + let iss = redirectComponents.queryItems?.first(where: { $0.name == "iss" })?.value, + let state = redirectComponents.queryItems?.first(where: { $0.name == "state" })?.value + else { + throw ATProtoOAuthError.malformedAuthorizationCallback + } + + if state != params.stateToken { + throw AuthenticatorError.stateTokenMismatch(state, params.stateToken) + } + + guard let tokenURL = URL(string: server.tokenEndpoint) else { + throw AuthenticatorError.missingTokenURL + } + guard let verifier = params.pcke?.verifier else { + throw AuthenticatorError.pkceRequired + } + + let tokenRequest = OAuthTokenRequest( + code: authCode, + codeVerifier: verifier, + redirectURI: params.credentials.callbackURL.absoluteString, + grantType: "authorization_code", + clientID: params.credentials.clientId + ) + + var request = URLRequest(url: tokenURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try JSONEncoder().encode(tokenRequest) + + let (data, response) = try await params.responseProvider(request) + guard let httpResponse = response as? HTTPURLResponse else { + throw AuthenticatorError.httpResponseExpected + } + guard (200..<300).contains(httpResponse.statusCode) else { + throw ATProtoOAuthError.tokenRequestFailed(String(decoding: data, as: UTF8.self)) + } + + let tokenResponse = try Self.decodeTokenResponse(from: data) + guard tokenResponse.tokenType == "DPoP" else { + throw AuthenticatorError.dpopTokenExpected(tokenResponse.tokenType) + } + guard tokenResponse.scopes.contains("atproto") else { + throw ATProtoOAuthError.missingRequiredScope("atproto") + } + + if iss != server.issuer { + throw AuthenticatorError.issuingServerMismatch(iss, server.issuer) + } + try Self.validateIssuer(iss, matches: expectedAuthorizationServer) + + if let expectedSubjectDID, tokenResponse.subject != expectedSubjectDID { + throw ATProtoOAuthError.subjectMismatch(expected: expectedSubjectDID, actual: tokenResponse.subject) + } + + return tokenResponse.login(for: iss) + } + } + + private func refreshProvider( + server: ServerMetadata, + expectedSubjectDID: String? + ) -> TokenHandling.RefreshProvider { + { login, credentials, responseProvider in + guard let refreshToken = login.refreshToken?.value else { + throw AuthenticatorError.refreshNotPossible + } + guard let tokenURL = URL(string: server.tokenEndpoint) else { + throw AuthenticatorError.missingTokenURL + } + + let tokenRequest = OAuthRefreshTokenRequest( + refreshToken: refreshToken, + redirectURI: credentials.callbackURL.absoluteString, + grantType: "refresh_token", + clientID: credentials.clientId + ) + + var request = URLRequest(url: tokenURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(tokenRequest) + + let (data, response) = try await responseProvider(request) + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) else { + throw AuthenticatorError.refreshNotPossible + } + + let tokenResponse = try Self.decodeTokenResponse(from: data) + guard tokenResponse.tokenType == "DPoP" else { + throw AuthenticatorError.dpopTokenExpected(tokenResponse.tokenType) + } + guard tokenResponse.scopes.contains("atproto") else { + throw ATProtoOAuthError.missingRequiredScope("atproto") + } + + if let expectedSubjectDID, tokenResponse.subject != expectedSubjectDID { + throw ATProtoOAuthError.subjectMismatch(expected: expectedSubjectDID, actual: tokenResponse.subject) + } + + return tokenResponse.login(for: login.issuingServer ?? server.issuer) + } + } + + nonisolated private static func decodeTokenResponse(from data: Data) throws -> OAuthTokenResponse { + do { + return try JSONDecoder().decode(OAuthTokenResponse.self, from: data) + } catch { + throw ATProtoOAuthError.invalidTokenResponse + } + } + + nonisolated private static func validateIssuer(_ issuer: String, matches expectedAuthorizationServer: String) throws { + guard let issuerURL = URL(string: issuer), + let expectedURL = URL(string: expectedAuthorizationServer), + normalizedOrigin(issuerURL) == normalizedOrigin(expectedURL) else { + throw ATProtoOAuthError.issuerMismatch(expected: expectedAuthorizationServer, actual: issuer) + } + } + + nonisolated private static func normalizedOrigin(_ url: URL) -> String? { + guard let scheme = url.scheme?.lowercased(), + let host = url.host?.lowercased() else { + return nil + } + let port = url.port.map { ":\($0)" } ?? "" + return "\(scheme)://\(host)\(port)" + } } // MARK: - DPoP Payload (from AtProto.swift lines 88-98) @@ -427,5 +669,66 @@ { request in try await URLSession.shared.data(for: request) } + } +} + +private struct OAuthTokenRequest: Codable { + let code: String + let codeVerifier: String + let redirectURI: String + let grantType: String + let clientID: String + + enum CodingKeys: String, CodingKey { + case code + case codeVerifier = "code_verifier" + case redirectURI = "redirect_uri" + case grantType = "grant_type" + case clientID = "client_id" + } +} + +private struct OAuthRefreshTokenRequest: Codable { + let refreshToken: String + let redirectURI: String + let grantType: String + let clientID: String + + enum CodingKeys: String, CodingKey { + case refreshToken = "refresh_token" + case redirectURI = "redirect_uri" + case grantType = "grant_type" + case clientID = "client_id" + } +} + +private struct OAuthTokenResponse: Codable { + let accessToken: String + let refreshToken: String? + let subject: String + let scope: String + let tokenType: String + let expiresIn: Int + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case subject = "sub" + case scope + case tokenType = "token_type" + case expiresIn = "expires_in" + } + + var scopes: Set { + Set(scope.split(separator: " ").map(String.init)) + } + + func login(for issuingServer: String) -> Login { + Login( + accessToken: Token(value: accessToken, expiresIn: expiresIn), + refreshToken: refreshToken.map { Token(value: $0) }, + scopes: scope, + issuingServer: issuingServer + ) } } diff --git a/Sources/CoreATProtocol/OAuth/IdentityResolver.swift b/Sources/CoreATProtocol/OAuth/IdentityResolver.swift --- a/Sources/CoreATProtocol/OAuth/IdentityResolver.swift +++ b/Sources/CoreATProtocol/OAuth/IdentityResolver.swift @@ -4,8 +4,10 @@ case invalidHandle case invalidDID case resolutionFailed + case invalidResponse case noPDSFound case noAuthServerFound + case handleVerificationFailed } /// Resolves AT Protocol identities: handle -> DID -> PDS -> Auth Server @@ -13,17 +15,16 @@ public struct IdentityResolver: Sendable { public struct ResolvedIdentity: Sendable { - public let handle: String + public let handle: String? public let did: String public let pdsEndpoint: String public let authorizationServer: String /// Server hostname for OAuthenticator's ServerMetadata.load() public var authServerHost: String { - if authorizationServer.hasPrefix("https://") { - return String(authorizationServer.dropFirst(8)) - } else if authorizationServer.hasPrefix("http://") { - return String(authorizationServer.dropFirst(7)) + if let url = URL(string: authorizationServer), + let host = url.host { + return host } return authorizationServer } @@ -31,17 +32,32 @@ public init() {} + /// Full resolution from either handle or DID. + public func resolve(identifier: String) async throws -> ResolvedIdentity { + if identifier.hasPrefix("did:") { + return try await resolve(did: identifier) + } + return try await resolve(handle: identifier) + } + /// Full resolution: handle -> all identity info needed for OAuth public func resolve(handle: String) async throws -> ResolvedIdentity { let cleanHandle = handle.replacingOccurrences(of: "@", with: "") + guard !cleanHandle.isEmpty else { + throw IdentityError.invalidHandle + } // Step 1: Handle -> DID let did = try await resolveHandle(cleanHandle) - // Step 2: DID -> PDS - let pds = try await resolvePDS(did: did) + // Step 2: DID document and bidirectional handle verification + let document = try await resolveDIDDocument(did: did) + try verifyHandle(cleanHandle, in: document) - // Step 3: PDS -> Auth Server + // Step 3: DID -> PDS + let pds = try pdsEndpoint(from: document) + + // Step 4: PDS -> Auth Server let authServer = try await discoverAuthServer(pdsURL: pds) return ResolvedIdentity( @@ -50,6 +66,43 @@ pdsEndpoint: pds, authorizationServer: authServer ) + } + + /// Full resolution from DID. + public func resolve(did: String) async throws -> ResolvedIdentity { + guard did.hasPrefix("did:") else { + throw IdentityError.invalidDID + } + + let document = try await resolveDIDDocument(did: did) + let pds = try pdsEndpoint(from: document) + let authServer = try await discoverAuthServer(pdsURL: pds) + + return ResolvedIdentity( + handle: handleFromDIDDocument(document), + did: did, + pdsEndpoint: pds, + authorizationServer: authServer + ) + } + + /// Checks whether an authorization server is currently valid for the given PDS. + public func isAuthorizationServer( + _ authorizationServer: String, + validFor pdsEndpoint: String + ) async throws -> Bool { + let metadata = try await protectedResourceMetadata(for: pdsEndpoint) + guard let authorizationServerURL = URL(string: authorizationServer), + let inputOrigin = normalizedOrigin(from: authorizationServerURL) else { + return false + } + + let allowedOrigins: [String] = metadata.authorizationServers.compactMap { value in + guard let url = URL(string: value) else { return nil } + return normalizedOrigin(from: url) + } + + return allowedOrigins.contains(inputOrigin) } // MARK: - Handle -> DID @@ -94,7 +147,11 @@ var request = URLRequest(url: dohURL) request.setValue("application/dns-json", forHTTPHeaderField: "Accept") - let (data, _) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw IdentityError.invalidResponse + } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let answers = json["Answer"] as? [[String: Any]] else { @@ -116,9 +173,9 @@ throw IdentityError.resolutionFailed } - // MARK: - DID -> PDS + // MARK: - DID document - private func resolvePDS(did: String) async throws -> String { + private func resolveDIDDocument(did: String) async throws -> DIDDocument { let url: URL if did.hasPrefix("did:plc:") { guard let plcURL = URL(string: "https://plc.directory/\(did)") else { @@ -135,31 +192,94 @@ throw IdentityError.invalidDID } - let (data, _) = try await URLSession.shared.data(from: url) - let document = try JSONDecoder().decode(DIDDocument.self, from: data) + let (data, response) = try await URLSession.shared.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw IdentityError.invalidResponse + } + let document = try JSONDecoder().decode(DIDDocument.self, from: data) + guard document.id == did else { + throw IdentityError.invalidDID + } + return document + } + + private func pdsEndpoint(from document: DIDDocument) throws -> String { guard let pds = document.pdsEndpoint else { throw IdentityError.noPDSFound } - return pds } // MARK: - PDS -> Auth Server private func discoverAuthServer(pdsURL: String) async throws -> String { + let metadata = try await protectedResourceMetadata(for: pdsURL) + guard let authServer = metadata.authorizationServers.first else { + throw IdentityError.noAuthServerFound + } + guard let authServerURL = URL(string: authServer), + normalizedOrigin(from: authServerURL) != nil else { + throw IdentityError.noAuthServerFound + } + return authServer + } + + private func protectedResourceMetadata(for pdsURL: String) async throws -> ResourceServerMetadata { guard let metadataURL = URL(string: "\(pdsURL)/.well-known/oauth-protected-resource") else { throw IdentityError.noAuthServerFound } - let (data, _) = try await URLSession.shared.data(from: metadataURL) - let metadata = try JSONDecoder().decode(ResourceServerMetadata.self, from: data) - - guard let authServer = metadata.authorizationServers.first else { - throw IdentityError.noAuthServerFound + let (data, response) = try await URLSession.shared.data(from: metadataURL) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw IdentityError.invalidResponse } - return authServer + let metadata = try JSONDecoder().decode(ResourceServerMetadata.self, from: data) + guard !metadata.authorizationServers.isEmpty else { + throw IdentityError.noAuthServerFound + } + return metadata + } + + private func verifyHandle(_ handle: String, in document: DIDDocument) throws { + guard let alsoKnownAs = document.alsoKnownAs else { + throw IdentityError.handleVerificationFailed + } + + let expected = handle.lowercased() + let claimedHandles = Set(alsoKnownAs.compactMap { parseHandle(from: $0)?.lowercased() }) + guard claimedHandles.contains(expected) else { + throw IdentityError.handleVerificationFailed + } + } + + private func handleFromDIDDocument(_ document: DIDDocument) -> String? { + document.alsoKnownAs?.compactMap(parseHandle(from:)).first + } + + private func parseHandle(from alias: String) -> String? { + if alias.hasPrefix("at://") { + let handle = String(alias.dropFirst(5)) + return handle.isEmpty ? nil : handle + } + if alias.hasPrefix("https://"), + let url = URL(string: alias), + let host = url.host { + return host + } + return nil + } + + private func normalizedOrigin(from url: URL) -> String? { + guard let scheme = url.scheme?.lowercased(), + let host = url.host?.lowercased() else { + return nil + } + let port = url.port.map { ":\($0)" } ?? "" + return "\(scheme)://\(host)\(port)" } } @@ -171,7 +291,9 @@ let service: [DIDService]? var pdsEndpoint: String? { - service?.first { $0.id.hasSuffix("#atproto_pds") }?.serviceEndpoint + service?.first { + $0.id.hasSuffix("#atproto_pds") || $0.type == "AtprotoPersonalDataServer" + }?.serviceEndpoint } } -- tangled.sh