Say when a 401 means only that the proof was spent
Every AuthError but one collapsed into the same `unauthorized` code, so a client that got a 401 had nothing to go on but the prose, and the sensible reading of "unauthorized" is that the token is bad. It usually isn't. A proof of possession is single use, and the request that spends one is frequently not a request the app made at all: URLSession replays an idempotent GET onto a new interface when a phone moves between wi-fi and cellular, headers and all. The relay refuses the replay, correctly. The client then throws away a perfectly good token and mints another before it can carry on, and during a call that round trip lands in front of the signalling. Seen in production: an iPhone flapped between wi-fi and cellular twice while a call was being set up, and paid for it each time. So a spent proof gets its own code, and says what to do about it. Still a 401, because the request was not authorised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
80c828bbb5
commit
eaf5e12227
@@ -101,6 +101,17 @@ struct Routes: Sendable {
|
||||
"This device was removed from the account. Its local "
|
||||
+ "messages are being cleared; sign in to register it "
|
||||
+ "as a new device.", 410)
|
||||
} catch AuthError.replayed {
|
||||
// Still 401, but its own code. A replayed proof says nothing about
|
||||
// the token: the usual cause is the client's own HTTP stack
|
||||
// retrying a request transparently across a network change, with
|
||||
// the headers it already built. A client that cannot tell this
|
||||
// apart throws away a perfectly good token and mints another,
|
||||
// which is a round trip in front of whatever it was doing.
|
||||
throw WireError(
|
||||
"proof_replayed",
|
||||
AuthError.replayed.description + "; sign the request again, the token is fine",
|
||||
401)
|
||||
} catch let e as AuthError {
|
||||
throw WireError.unauthorized(e.description)
|
||||
}
|
||||
|
||||
@@ -409,5 +409,36 @@ extension EndToEndTests {
|
||||
afterResponse: { #expect($0.status == .unauthorized) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A client whose HTTP stack retried a request across a network change
|
||||
/// arrives here holding a fine token and a spent proof. It has to be
|
||||
/// able to tell that apart from a token problem, or it discards the
|
||||
/// token and mints another before it can carry on.
|
||||
@Test("a spent proof says so, rather than just saying unauthorized")
|
||||
func replayHasItsOwnCode() async throws {
|
||||
try await withRelayApp { app in
|
||||
let ada = try await session(app, "ada", device: "aaaa-0001")
|
||||
let ikSig = ada.key.publicKey.rawRepresentation.base64EncodedString()
|
||||
let body =
|
||||
#"{"device_id":"aaaa-0001","ik_dh":"\#(ikSig)","ik_sig":"\#(ikSig)","spk_id":1,"spk":"spk","spk_sig":"sig"}"#
|
||||
let headers = authed(ada, .POST, "/e2e/devices", body: body)
|
||||
|
||||
try await app.testing().test(
|
||||
.POST, "/e2e/devices", headers: headers,
|
||||
beforeRequest: { $0.body = .init(string: body) },
|
||||
afterResponse: { #expect($0.status == .ok) })
|
||||
|
||||
try await app.testing().test(
|
||||
.POST, "/e2e/devices", headers: headers,
|
||||
beforeRequest: { $0.body = .init(string: body) },
|
||||
afterResponse: { response in
|
||||
#expect(response.status == .unauthorized)
|
||||
let code = try? response.content.get(String.self, at: "code")
|
||||
#expect(
|
||||
code == "proof_replayed",
|
||||
"a spent proof answered with \(code ?? "no code")")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user