1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
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() }
}
}