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
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (C) 2026 Matthew Jackson
//! The ORDER of the checks in the authorization code path, which is a security property in its own
//! right (RFC 6749 section 4.1.3, RFC 9700 section 4.1.1).
//!
//! Replaying a code revokes the tokens that code already minted. That is correct, and it is also a
//! destructive action, so it must be reachable only by the client the code was issued to. If the
//! revocation branch is evaluated before the ownership check, the destructive half of replay
//! handling becomes available to any caller who can name a registered `client_id` and holds a
//! leaked code, without presenting a redirect URI, a PKCE verifier, or (for a public client) any
//! credential at all.
//!
//! The second property pinned here is retention: a consumed code is kept until its own expiry, so
//! that replay detection works more than once. `src/authorization.rs` and `src/store.rs` both
//! promise this in prose, and a promise with no test is a promise nobody has to keep.
mod support;
use oauth_as::server::UserApproval;
use oauth_as::{ClientId, ErrorCode, Storage, TokenRequest};
use support::{
confidential_client, mint_code_token_keeping_code, public_client, server_with, ManualClock,
CONFIDENTIAL_REDIRECT, CONFIDENTIAL_SECRET,
};
/// THE ATTACK (RFC 6749 section 4.1.3 client binding; RFC 9700 section 4.1.1 replay handling): an
/// attacker reads a leaked authorization code out of a log or a `Referer` header, then POSTs it to
/// the token endpoint naming an UNRELATED registered public client. A public client has no
/// credential to present, so this costs the attacker nothing. If the replay-revocation branch runs
/// before the code's client is checked, that request deletes the victim's live access token and
/// refresh chain: a remote kill switch reached with no authentication and no PKCE verifier.
#[tokio::test]
async fn a_stranger_replaying_a_leaked_code_cannot_revoke_the_victims_tokens() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![confidential_client(), public_client()]).await;
// The victim completes an ordinary flow. `mint_code_token` redeems the code, so the record is
// now retained in the consumed state, which is the state the attack needs.
let (issued, leaked_code) = mint_code_token_keeping_code(
&srv,
"confidential-app",
Some(CONFIDENTIAL_SECRET),
CONFIDENTIAL_REDIRECT,
"read",
"user-1",
)
.await;
let refresh_token = issued.refresh_token.expect("the code grant issues a chain");
// The attacker: someone else's code, their own (public, unauthenticated) client id, no
// redirect_uri, no code_verifier.
let err = srv
.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("public-app"),
client_secret: None,
code: leaked_code,
redirect_uri: None,
code_verifier: None,
})
.await
.expect_err("a code presented by a client it was not issued to is invalid_grant");
assert_eq!(err.error, ErrorCode::InvalidGrant);
// Nothing of the victim's may have been touched.
let resp = srv
.introspection_response(
&ClientId::new("confidential-app"),
Some(CONFIDENTIAL_SECRET),
&issued.access_token,
)
.await
.unwrap();
assert!(
resp.active,
"a stranger's replay must not revoke the victim's access token"
);
srv.token(TokenRequest::RefreshToken {
client_id: ClientId::new("confidential-app"),
client_secret: Some(CONFIDENTIAL_SECRET.to_string()),
refresh_token,
scope: None,
})
.await
.expect("a stranger's replay must not revoke the victim's refresh chain");
}
/// RFC 9700 section 4.1.1 and the retention contract stated in `src/authorization.rs`: a consumed
/// code is kept until expiry. Taking the record out of the store on the FIRST replay makes replay
/// detection one-shot, so a later genuine replay reads as an unknown code and revokes nothing.
#[tokio::test]
async fn replay_detection_is_not_one_shot() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![confidential_client()]).await;
let (_issued, code) = mint_code_token_keeping_code(
&srv,
"confidential-app",
Some(CONFIDENTIAL_SECRET),
CONFIDENTIAL_REDIRECT,
"read",
"user-1",
)
.await;
for attempt in 0..2 {
let err = srv
.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("confidential-app"),
client_secret: Some(CONFIDENTIAL_SECRET.to_string()),
code: code.clone(),
redirect_uri: Some(CONFIDENTIAL_REDIRECT.to_string()),
code_verifier: Some(support::RFC7636_VERIFIER.to_string()),
})
.await
.unwrap_err();
assert_eq!(
err.error,
ErrorCode::InvalidGrant,
"replay {attempt} must be refused"
);
}
// The record must still be there: that is what makes the SECOND replay a recognised replay
// rather than an unknown code.
let retained = srv
.store()
.take_authorization_code(&code)
.await
.unwrap()
.expect("the consumed code must be retained until its own expiry");
// `Replayed` rather than `Consumed`, and this is a STRONGER assertion than the one it
// replaced rather than an accommodation of a behaviour change. The property the old assertion
// was about is unchanged and is checked on the line below: the record is retained and still
// names what the code minted, which is what makes a second replay a recognised replay.
//
// What is new is that the state now records that a replay HAPPENED, durably. A redemption
// suspended on the host's signer reads exactly this to discover that the grant it is halfway
// through issuing was contained while it slept. See `AuthorizationCodeState::Replayed`.
assert!(
matches!(
retained.state,
oauth_as::AuthorizationCodeState::Replayed { .. }
),
"a detected replay must leave a durable trace, not put the record back unchanged"
);
assert!(
retained.state.minted().is_some(),
"the retained record must still say what the code minted"
);
}
/// A code presented by the wrong client must not be BURNED either: the real client has to be able
/// to finish its flow. The record goes back untouched, in the `Issued` state, and redeems
/// normally afterwards (RFC 6749 section 4.1.3).
#[tokio::test]
async fn a_wrong_client_presentation_leaves_an_unredeemed_code_usable() {
let clock = ManualClock::at_epoch();
let srv = server_with(clock, vec![confidential_client(), public_client()]).await;
let challenge = oauth_as::pkce::code_challenge_s256(support::RFC7636_VERIFIER);
let req = oauth_as::AuthorizationRequest::from_pairs([
("response_type", "code"),
("client_id", "confidential-app"),
("redirect_uri", CONFIDENTIAL_REDIRECT),
("scope", "read"),
("code_challenge", challenge.as_str()),
("code_challenge_method", "S256"),
]);
let validated = srv.validate_authorization_request(&req).await.unwrap();
let response = srv
.issue_authorization_code(UserApproval::granted(&validated, "user-1"))
.await
.unwrap();
let err = srv
.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("public-app"),
client_secret: None,
code: response.code.clone(),
redirect_uri: None,
code_verifier: None,
})
.await
.expect_err("another client may not redeem this code");
assert_eq!(err.error, ErrorCode::InvalidGrant);
srv.token(TokenRequest::AuthorizationCode {
client_id: ClientId::new("confidential-app"),
client_secret: Some(CONFIDENTIAL_SECRET.to_string()),
code: response.code,
redirect_uri: Some(CONFIDENTIAL_REDIRECT.to_string()),
code_verifier: Some(support::RFC7636_VERIFIER.to_string()),
})
.await
.expect("the code's real owner must still be able to redeem it");
}