~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
161 lines · 7.0 KB Raw History
import Foundation

/// An account as its DID document describes it.
struct ResolvedIdentity: Sendable, Equatable {
    let did: String
    /// The handle the DID document claims, when it resolves back to the DID.
    let handle: String?
    /// The `#atproto_pds` service endpoint.
    let pds: URL
}

/// Handle and DID resolution, as atproto's OAuth asks a client to do before
/// it knows where an account signs in.
///
/// A handle resolves through its own `/.well-known/atproto-did` first, then
/// through `com.atproto.identity.resolveHandle` at the AppView: the resolvers,
/// in the order, that grain's CLI uses. A DID document comes from the PLC
/// directory for `did:plc`, or from its host for `did:web`.
struct IdentityResolver: Sendable {
    let session: URLSession
    var handleService = URL(string: "https://public.api.bsky.app")!
    var plcDirectory = URL(string: "https://plc.directory")!

    /// How long a lookup at a host the name alone points to may take: any
    /// name can point at a host that takes the connection and never answers.
    static let lookupTimeout: TimeInterval = 3

    /// Resolve what the user typed, a handle (with or without `@`) or a DID.
    func resolve(_ identifier: String) async throws -> ResolvedIdentity {
        let typed = Self.normalize(identifier)
        guard !typed.isEmpty else { throw OAuthError.handleRequired }

        if typed.hasPrefix("did:") {
            let document = try await document(for: typed)
            let pds = try Self.pds(in: document, did: typed)
            // Use the handle the document claims only if it resolves back.
            var handle: String?
            if let claimed = document.handle, let back = try? await resolveHandle(claimed), back == typed {
                handle = claimed
            }
            return ResolvedIdentity(did: typed, handle: handle, pds: pds)
        }

        guard Self.looksLikeHandle(typed) else { throw OAuthError.handleNotFound(typed) }
        let did = try await resolveHandle(typed)
        let document = try await document(for: did)
        let pds = try Self.pds(in: document, did: did)
        let claims = document.alsoKnownAs?.contains { $0.lowercased() == "at://\(typed)" } ?? false
        return ResolvedIdentity(did: did, handle: claims ? typed : nil, pds: pds)
    }

    /// A handle's DID, from the first resolver that answers with one.
    func resolveHandle(_ handle: String) async throws -> String {
        if let did = try? await wellKnown(handle) {
            return did
        }
        var components = URLComponents(
            url: handleService.appendingPathComponent("xrpc/com.atproto.identity.resolveHandle"),
            resolvingAgainstBaseURL: false
        )!
        components.queryItems = [URLQueryItem(name: "handle", value: handle)]
        struct Resolved: Decodable { let did: String }
        if let url = components.url,
           case let (data, response)? = try? await session.data(from: url),
           (response as? HTTPURLResponse)?.statusCode == 200,
           let resolved = try? JSONDecoder().decode(Resolved.self, from: data),
           resolved.did.hasPrefix("did:")
        {
            return resolved.did
        }
        throw OAuthError.handleNotFound(handle)
    }

    private func wellKnown(_ handle: String) async throws -> String {
        guard let url = URL(string: "https://\(handle)/.well-known/atproto-did") else { throw OAuthError.handleNotFound(handle) }
        var request = URLRequest(url: url)
        request.timeoutInterval = Self.lookupTimeout
        let (data, response) = try await session.data(for: request)
        guard (response as? HTTPURLResponse)?.statusCode == 200,
              let did = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
              did.hasPrefix("did:")
        else { throw OAuthError.handleNotFound(handle) }
        return did
    }

    /// `did`'s DID document.
    func document(for did: String) async throws -> DIDDocument {
        var request: URLRequest
        if did.hasPrefix("did:plc:") {
            request = URLRequest(url: plcDirectory.appendingPathComponent(did))
        } else if did.hasPrefix("did:web:") {
            let host = String(did.dropFirst("did:web:".count)).replacingOccurrences(of: "%3A", with: ":")
            guard let url = URL(string: "https://\(host)/.well-known/did.json") else { throw OAuthError.unsupportedDID(did) }
            request = URLRequest(url: url)
            request.timeoutInterval = Self.lookupTimeout
        } else {
            throw OAuthError.unsupportedDID(did)
        }
        let (data, response) = try await session.data(for: request)
        guard (response as? HTTPURLResponse)?.statusCode == 200,
              let document = try? JSONDecoder().decode(DIDDocument.self, from: data)
        else { throw OAuthError.noPDS(did) }
        return document
    }

    static func pds(in document: DIDDocument, did: String) throws -> URL {
        guard let endpoint = document.service?.first(where: { $0.id.hasSuffix("#atproto_pds") })?.serviceEndpoint,
              let url = URL(string: endpoint), url.scheme == "https" || url.scheme == "http"
        else { throw OAuthError.noPDS(did) }
        return url
    }

    static func normalize(_ identifier: String) -> String {
        var typed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
        if typed.hasPrefix("@") {
            typed.removeFirst()
        }
        return typed.hasPrefix("did:") ? typed : typed.lowercased()
    }

    /// Enough of atproto's handle syntax to keep a typo off the network:
    /// dot-separated labels of letters, digits and hyphens.
    static func looksLikeHandle(_ handle: String) -> Bool {
        let labels = handle.split(separator: ".", omittingEmptySubsequences: false)
        guard labels.count >= 2, handle.count <= 253 else { return false }
        let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
        return labels.allSatisfy { label in
            !label.isEmpty && label.count <= 63 && !label.hasPrefix("-") && !label.hasSuffix("-")
                && label.unicodeScalars.allSatisfy { allowed.contains($0) }
        }
    }
}

/// The parts of a DID document sign-in reads.
struct DIDDocument: Decodable, Sendable {
    struct Service: Decodable, Sendable {
        let id: String
        let serviceEndpoint: String?

        enum CodingKeys: String, CodingKey {
            case id
            case serviceEndpoint
        }

        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            id = try container.decode(String.self, forKey: .id)
            // An endpoint may be an object or a list; only a URL string is a PDS.
            serviceEndpoint = try? container.decode(String.self, forKey: .serviceEndpoint)
        }
    }

    let id: String?
    let alsoKnownAs: [String]?
    let service: [Service]?

    /// The handle in the first `at://` alias.
    var handle: String? {
        alsoKnownAs?.first { $0.hasPrefix("at://") }.map { String($0.dropFirst("at://".count)).lowercased() }
    }
}