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:
Paul Hitt
2026-09-10 08:46:53 -04:00
co-authored by Claude Opus 5
parent 80c828bbb5
commit eaf5e12227
2 changed files with 42 additions and 0 deletions
+11
View File
@@ -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")")
})
}
}
}
}