From 1b1a41c19db87ef9e04b90a4fed9b81d3134f3f2 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 24 Sep 2026 19:17:05 -0400 Subject: [PATCH] docs(oauth): say a code is exchanged only under its sign-in's DPoP key The plan records the binding as built, and conformance.md names where the reference provider's errors for a mismatch differ. Co-Authored-By: Claude Opus 5.5 (1M context) Change-Id: Ia20ff7a5e89f629a8cf40f335e73e1488fd46d52 --- docs/conformance.md | 11 ++++++++++- plan/oauth.md | 7 +++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/conformance.md b/docs/conformance.md index e2a2d621..f7f99d14 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -543,10 +543,19 @@ the two endpoints carries `Cache-Control: no-store` and `Pragma: no-cache` one `DPoP` header is refused as a bad proof, at both endpoints and on the resource routes (RFC 9449 §4.3). +A code is exchanged only under the key its sign-in's push was signed with +(RFC 9449 §10). The thumbprint of that key is kept with the pushed request, +carried through its consent to the code, and compared at the exchange. A +proof under any other key is `invalid_dpop_proof`, and the code is spent. A +push that also names its key in `dpop_jkt` must name the same one (§10.1). + The reference provider accepts a push with no proof, which the profile forbids, and this server refuses it. It also answers `invalid_dpop_proof` with a 401 at the authorization server, where RFC 6749 §5.2 and this server -use a 400. jacquard-oauth's own client signs in, writes and refreshes under +use a 400. It answers a code under another key with `invalid_grant`, and a +`dpop_jkt` naming another key with a 401 `invalid_token`. RFC 9449 names no +error for either, so this server answers its `invalid_dpop_proof`, with RFC +6749 §5.2's 400. jacquard-oauth's own client signs in, writes and refreshes under these rules in `crates/didbot-serve/tests/oauth_standard_client.rs`, and `@atproto/oauth-client-node` does in `crates/didbot-serve/tests/oauth_interop.rs`. diff --git a/plan/oauth.md b/plan/oauth.md index 0d48f09d..3d38f4f8 100644 --- a/plan/oauth.md +++ b/plan/oauth.md @@ -438,6 +438,13 @@ as above, and for that one the page says the sign-in was approved. than one `DPoP` header is refused as a bad proof wherever one is read. Answers from `par` and `token` are uncacheable (RFC 6749 §5.1). Tests: `oauth_standard_client.rs`'s push tests and `dpop_binding.rs`. +- [x] **A code is bound to the key its sign-in began with.** RFC 9449 §10: + the thumbprint of the push's DPoP key is kept with the pushed request, + its pending consent and its code, all in memory, and the exchange's + proof must be signed with the same key, or it is `invalid_dpop_proof` + and the code is spent. A push whose `dpop_jkt` names another key is + refused the same way. Tests: `oauth_account_flow.rs` and + `dpop_binding.rs`. - [x] **The authorization server.** Pushed authorization requests, authorize, token, refresh rotation, client metadata fetching and validation. `crates/didbot-serve/src/oauth/{par,authorize,token,client_metadata}.rs`. -- 2.51.2