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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
@testable import Grain
import XCTest
private struct Echo: Codable, Equatable {
let value: String
}
private struct Input: Encodable {
let key: String
}
/// The shape of a signed-in call: it goes to the account's PDS, proves
/// possession of the token's key per request, carries the nonce each origin
/// asks for, and asks the PDS to proxy Grain's own methods to the AppView.
final class PDSCallTests: GrainTestCase {
private let baseURL = URL(string: "https://test.local")!
override func tearDown() {
MockURLProtocol.handler = nil
super.tearDown()
}
private func makeClient() -> XRPCClient {
XRPCClient(baseURL: baseURL, session: MockURLProtocol.mockSession())
}
private func respond(body: String = #"{"value": "ok"}"#, record: (@Sendable (URLRequest) -> Void)? = nil) {
MockURLProtocol.handler = { request in
record?(request)
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(body.utf8), response)
}
}
private func proofClaims(of proof: String?) throws -> [String: Any] {
let parts = try XCTUnwrap(proof).split(separator: ".")
XCTAssertEqual(parts.count, 3)
var payload = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
while payload.count % 4 != 0 {
payload += "="
}
let data = try XCTUnwrap(Data(base64Encoded: payload))
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
/// A signed-in call goes to the account's PDS, not the AppView, and asks
/// the PDS to proxy it to Grain's AppView.
func testASignedInGrainCallGoesToThePDSThroughTheProxy() async throws {
let dpop = DPoP(privateKey: .init())
let pds = try XCTUnwrap(URL(string: "https://pds.example"))
let auth = AuthContext(accessToken: "token", dpop: dpop, did: "did:plc:me", pds: pds)
let seen = Recorder()
respond { seen.record($0) }
_ = try await makeClient().query("social.grain.feed.getFeed", params: ["feed": "recent"], auth: auth, as: Echo.self)
XCTAssertEqual(seen.url?.host, "pds.example")
XCTAssertEqual(seen.url?.path, "/xrpc/social.grain.feed.getFeed")
XCTAssertEqual(seen.headers?["atproto-proxy"], "\(AuthManager.appviewDID)#grain_appview")
XCTAssertEqual(seen.headers?["Authorization"], "DPoP token")
}
/// `com.atproto.*` is the PDS's own: no proxy.
func testARepoCallIsNotProxied() async throws {
let auth = try AuthContext(accessToken: "token", dpop: DPoP(privateKey: .init()), did: "did:plc:me", pds: XCTUnwrap(URL(string: "https://pds.example")))
let seen = Recorder()
respond(body: #"{"uri": "at://did:plc:me/social.grain.favorite/1", "cid": "c"}"#) { seen.record($0) }
_ = try await makeClient().procedure("com.atproto.repo.createRecord", input: Input(key: "k"), auth: auth, as: CreateRecordResponse.self)
XCTAssertEqual(seen.url?.host, "pds.example")
XCTAssertNil(seen.headers?["atproto-proxy"])
}
/// Signed out, a call goes straight to the AppView with no credentials.
func testASignedOutCallGoesToTheAppViewBare() async throws {
let seen = Recorder()
respond { seen.record($0) }
_ = try await makeClient().query("social.grain.feed.getFeed", as: Echo.self)
XCTAssertEqual(seen.url?.host, "test.local")
XCTAssertNil(seen.headers?["Authorization"])
XCTAssertNil(seen.headers?["DPoP"])
XCTAssertNil(seen.headers?["atproto-proxy"])
}
/// The proof names the method, the URL without its query, and the token
/// it is presented with.
func testTheProofBindsMethodURLAndToken() async throws {
let auth = try AuthContext(accessToken: "token", dpop: DPoP(privateKey: .init()), did: "did:plc:me", pds: XCTUnwrap(URL(string: "https://proof.example")))
let seen = Recorder()
respond { seen.record($0) }
_ = try await makeClient().query("social.grain.feed.getFeed", params: ["limit": "3"], auth: auth, as: Echo.self)
let claims = try proofClaims(of: seen.headers?["DPoP"])
XCTAssertEqual(claims["htm"] as? String, "GET")
XCTAssertEqual(claims["htu"] as? String, "https://proof.example/xrpc/social.grain.feed.getFeed")
XCTAssertEqual(claims["ath"] as? String, OAuthClient.challenge(for: "token"))
}
/// The retry after a nonce refusal carries the nonce, and later calls to
/// the same origin start with it.
func testTheNonceIsSentAgainAndKeptForTheOrigin() async throws {
let pds = try XCTUnwrap(URL(string: "https://nonce-\(UUID().uuidString.lowercased()).example"))
let auth = AuthContext(accessToken: "token", dpop: DPoP(privateKey: .init()), did: "did:plc:me", pds: pds)
let proofs = ProofLog()
MockURLProtocol.handler = { request in
let attempt = proofs.record(request.value(forHTTPHeaderField: "DPoP"))
let refuse = attempt == 1
let response = HTTPURLResponse(
url: request.url!, statusCode: refuse ? 401 : 200, httpVersion: nil,
headerFields: refuse
? ["DPoP-Nonce": "fresh", "WWW-Authenticate": #"DPoP error="use_dpop_nonce""#]
: ["DPoP-Nonce": "fresh"]
)!
return (Data(#"{"value": "ok"}"#.utf8), response)
}
_ = try await makeClient().query("social.grain.feed.getFeed", auth: auth, as: Echo.self)
_ = try await makeClient().query("social.grain.feed.getFeed", auth: auth, as: Echo.self)
let all = proofs.all
XCTAssertEqual(all.count, 3)
XCTAssertNil(try proofClaims(of: all[0])["nonce"])
XCTAssertEqual(try proofClaims(of: all[1])["nonce"] as? String, "fresh")
XCTAssertEqual(try proofClaims(of: all[2])["nonce"] as? String, "fresh", "the second call starts with the kept nonce")
}
/// A PDS sends `DPoP-Nonce` on every answer, a 401 for an expired token
/// included. That 401 is a refresh, not a nonce retry.
func testAnExpiredTokenWithANonceHeaderRefreshes() async throws {
let auth = try AuthContext(accessToken: "old", dpop: DPoP(privateKey: .init()), did: "did:plc:me", pds: XCTUnwrap(URL(string: "https://pds.example")))
let attempts = Counter()
MockURLProtocol.handler = { request in
let first = attempts.next() == 1
let response = HTTPURLResponse(
url: request.url!, statusCode: first ? 401 : 200, httpVersion: nil,
headerFields: first
? ["DPoP-Nonce": "n", "WWW-Authenticate": #"DPoP error="invalid_token", error_description="exp""#]
: nil
)!
return (Data(#"{"value": "ok"}"#.utf8), response)
}
let refreshed = Counter()
let client = XRPCClient(baseURL: baseURL, session: MockURLProtocol.mockSession()) { () -> AuthContext? in
refreshed.next()
return AuthContext(accessToken: "new", dpop: auth.dpop, did: auth.did, pds: auth.pds)
}
_ = try await client.query("social.grain.feed.getFeed", auth: auth, as: Echo.self)
XCTAssertEqual(refreshed.count, 1)
XCTAssertEqual(attempts.count, 2)
}
/// A 400 that merely carries a nonce header is an ordinary bad request.
func testABadRequestCarryingANonceHeaderIsNotRetried() async throws {
let auth = try AuthContext(accessToken: "t", dpop: DPoP(privateKey: .init()), did: "did:plc:me", pds: XCTUnwrap(URL(string: "https://pds.example")))
let attempts = Counter()
MockURLProtocol.handler = { request in
attempts.next()
let response = HTTPURLResponse(url: request.url!, statusCode: 400, httpVersion: nil, headerFields: ["DPoP-Nonce": "n"])!
return (Data(#"{"error": "InvalidRequest"}"#.utf8), response)
}
_ = try? await makeClient().query("social.grain.feed.getFeed", auth: auth, as: Echo.self)
XCTAssertEqual(attempts.count, 1)
}
}
private final class Recorder: @unchecked Sendable {
private let lock = NSLock()
private var request: URLRequest?
func record(_ request: URLRequest) {
lock.withLock { self.request = request }
}
var url: URL? {
lock.withLock { request?.url }
}
var headers: [String: String]? {
lock.withLock { request?.allHTTPHeaderFields }
}
}
private final class ProofLog: @unchecked Sendable {
private let lock = NSLock()
private var proofs: [String?] = []
func record(_ proof: String?) -> Int {
lock.withLock {
proofs.append(proof)
return proofs.count
}
}
var all: [String?] {
lock.withLock { proofs }
}
}
private final class Counter: @unchecked Sendable {
private let lock = NSLock()
private var value = 0
@discardableResult
func next() -> Int {
lock.withLock {
value += 1
return value
}
}
var count: Int {
lock.withLock { value }
}
}