soma_auth/revoke.rs
1//! RFC 7009 OAuth 2.0 Token Revocation (`POST /revoke`).
2//!
3//! Two properties drive every decision in this module:
4//!
5//! 1. **Revocation is idempotent and never an oracle.** RFC 7009 section 2.2
6//! requires HTTP 200 both when a token was revoked and when the client
7//! "submitted an invalid token" -- unknown, already revoked, expired, or
8//! issued to somebody else. A caller must not be able to tell those apart
9//! from the response, so the success path below deliberately discards the
10//! store's did-it-delete boolean.
11//! 2. **A client may only revoke its own tokens.** RFC 7009 section 2.1 makes
12//! the server "verify whether the token was issued to the client making the
13//! revocation request". That check is the `client_id` predicate inside
14//! [`crate::sqlite::SqliteStore::revoke_refresh_token`]'s `DELETE`, so a
15//! request naming somebody else's token deletes nothing -- and, per (1),
16//! still answers 200.
17//!
18//! Only refresh tokens are revocable. Access tokens are self-contained
19//! EdDSA-signed JWTs (see `crate::jwt`) validated purely by signature and
20//! expiry, with no server-side record to delete and no denylist to add to, so
21//! `token_type_hint=access_token` is answered with RFC 7009 section 2.2.1's
22//! `unsupported_token_type` rather than a 200 that would misrepresent what
23//! happened. Revoking the refresh token still severs renewal; outstanding
24//! access tokens age out on their own (one hour by default).
25
26use axum::extract::{ConnectInfo, Form, State};
27use axum::http::{HeaderMap, StatusCode};
28use axum::response::{IntoResponse, Response};
29use std::net::SocketAddr;
30use tracing::{info, warn};
31
32use crate::error::AuthError;
33use crate::state::AuthState;
34use crate::token_client_auth;
35use crate::types::{RevocationRequest, TokenRequest};
36use crate::util::{apply_no_store, fingerprint, oauth_error_response, remote_ip};
37
38/// RFC 7009 section 2.1 `token_type_hint` value naming the one token type this
39/// server cannot revoke.
40const ACCESS_TOKEN_HINT: &str = "access_token";
41
42/// `POST /revoke` -- RFC 7009 token revocation.
43///
44/// Never logs, echoes, or returns the submitted token value; diagnostics use
45/// [`fingerprint`] only.
46pub async fn revoke(
47 State(state): State<AuthState>,
48 ConnectInfo(addr): ConnectInfo<SocketAddr>,
49 headers: HeaderMap,
50 Form(mut request): Form<RevocationRequest>,
51) -> Response {
52 // Rate-limit first, before client resolution touches the store or (for a
53 // CIMD-shaped `client_id`) makes an outbound metadata fetch -- the same
54 // reasoning that guards `/token`, which shares this limiter.
55 if let Err(error) = state.check_token_rate_limit(remote_ip(addr)).await {
56 return RevocationEndpointError::Auth(error).into_response();
57 }
58 match revoke_token(&state, &headers, &mut request).await {
59 Ok(()) => revocation_success(),
60 Err(error) => error.into_response(),
61 }
62}
63
64async fn revoke_token(
65 state: &AuthState,
66 headers: &HeaderMap,
67 request: &mut RevocationRequest,
68) -> Result<(), RevocationEndpointError> {
69 normalize_credentials(headers, request)?;
70 // Required even for public clients: it is the only thing scoping the
71 // delete to the caller's own tokens, so without it there is no way to
72 // honour RFC 7009 section 2.1's ownership check. Rejecting here is not an
73 // oracle -- the outcome depends solely on the request's own shape.
74 let client_id = request
75 .client_id
76 .as_deref()
77 .ok_or_else(|| AuthError::Validation("missing `client_id` parameter".to_string()))?;
78 token_client_auth::authenticate_oauth_client(
79 state,
80 client_id,
81 request.client_secret.as_deref(),
82 request.client_assertion_type.as_deref(),
83 request.client_assertion.as_deref(),
84 )
85 .await?;
86
87 // Checked after client authentication so an unauthenticated caller learns
88 // nothing about which token types this server supports. Decided purely
89 // from the hint, never from a lookup, so it cannot leak token existence.
90 if request.token_type_hint.as_deref() == Some(ACCESS_TOKEN_HINT) {
91 warn!(
92 client_id = %client_id,
93 "oauth revocation rejected: access tokens are stateless and cannot be revoked"
94 );
95 return Err(RevocationEndpointError::UnsupportedTokenType);
96 }
97
98 // Any other hint -- including a bogus one -- is ignored, per RFC 7009
99 // section 2.2: "An invalid token type hint value is ignored by the
100 // authorization server and does not influence the revocation response."
101 let revoked = state
102 .store
103 .revoke_refresh_token(&request.token, client_id)
104 .await?;
105 // `revoked` reaches the operator's logs and stops there. Branching the
106 // HTTP response on it is exactly the token-existence oracle RFC 7009
107 // forbids.
108 info!(
109 client_id = %client_id,
110 token_id = %fingerprint(&request.token),
111 revoked,
112 "oauth revocation processed"
113 );
114 Ok(())
115}
116
117/// Fold RFC 6749 section 2.3.1 HTTP Basic credentials into the body parameters
118/// so `/revoke` accepts exactly the client-authentication shapes `/token`
119/// does, without reimplementing security-sensitive credential parsing here.
120///
121/// [`token_client_auth::normalize_client_credentials`] is written against
122/// [`TokenRequest`] because that was the only request shape it had to serve,
123/// but every field it touches (`client_id`, `client_secret`,
124/// `client_assertion`, `client_assertion_type`, `assertion`) is client
125/// authentication rather than grant data, and is common to both requests. The
126/// shim's `grant_type` is left empty deliberately: the sole branch that reads
127/// it folds a JWT-bearer *authorization grant*, which a revocation request
128/// never carries.
129fn normalize_credentials(
130 headers: &HeaderMap,
131 request: &mut RevocationRequest,
132) -> Result<(), AuthError> {
133 // Only the four credential fields are meaningful here; `..Default::default()`
134 // keeps them the only thing a reader has to check. `grant_type` stays empty,
135 // which is safe: the one branch that reads it (`adopt_jwt_bearer_assertion`)
136 // can never fire, because `RevocationRequest` carries no `assertion` field.
137 let mut shim = TokenRequest {
138 client_id: request.client_id.take(),
139 client_secret: request.client_secret.take(),
140 client_assertion_type: request.client_assertion_type.take(),
141 client_assertion: request.client_assertion.take(),
142 ..Default::default()
143 };
144 token_client_auth::normalize_client_credentials(headers, &mut shim)?;
145 request.client_id = shim.client_id;
146 request.client_secret = shim.client_secret;
147 request.client_assertion_type = shim.client_assertion_type;
148 request.client_assertion = shim.client_assertion;
149 Ok(())
150}
151
152/// RFC 7009 section 2.2: 200 with no body. "The content of the response body
153/// is ignored by the client as all necessary information is conveyed in the
154/// response code."
155fn revocation_success() -> Response {
156 apply_no_store(StatusCode::OK.into_response())
157}
158
159/// Failures that reach the client as an RFC 6749 section 5.2 error object,
160/// which RFC 7009 section 2.2.1 adopts wholesale for this endpoint.
161enum RevocationEndpointError {
162 Auth(AuthError),
163 UnsupportedTokenType,
164}
165
166impl From<AuthError> for RevocationEndpointError {
167 fn from(error: AuthError) -> Self {
168 Self::Auth(error)
169 }
170}
171
172impl RevocationEndpointError {
173 fn oauth_error(&self) -> &'static str {
174 match self {
175 Self::UnsupportedTokenType => "unsupported_token_type",
176 Self::Auth(AuthError::InvalidGrant(_)) => "invalid_grant",
177 Self::Auth(AuthError::InvalidScope(_)) => "invalid_scope",
178 Self::Auth(AuthError::AuthFailed(_) | AuthError::InvalidAccessToken) => {
179 "invalid_client"
180 }
181 Self::Auth(AuthError::RateLimited { .. }) => "temporarily_unavailable",
182 Self::Auth(AuthError::Validation(_)) => "invalid_request",
183 Self::Auth(
184 AuthError::Config(_)
185 | AuthError::Storage(_)
186 | AuthError::Network(_)
187 | AuthError::Server(_)
188 | AuthError::Decode(_)
189 | AuthError::InsecurePermissions { .. },
190 ) => "server_error",
191 }
192 }
193
194 fn log_kind(&self) -> &'static str {
195 match self {
196 Self::Auth(error) => error.kind(),
197 Self::UnsupportedTokenType => "unsupported_token_type",
198 }
199 }
200
201 fn status(&self) -> StatusCode {
202 match self {
203 Self::UnsupportedTokenType
204 | Self::Auth(
205 AuthError::InvalidGrant(_) | AuthError::InvalidScope(_) | AuthError::Validation(_),
206 ) => StatusCode::BAD_REQUEST,
207 Self::Auth(AuthError::AuthFailed(_) | AuthError::InvalidAccessToken) => {
208 StatusCode::UNAUTHORIZED
209 }
210 Self::Auth(AuthError::RateLimited { .. }) => StatusCode::TOO_MANY_REQUESTS,
211 // A storage fault must never look like a successful revocation:
212 // RFC 7009 section 2.2.1 tells the client to assume the token
213 // still exists when the server reports a failure, which is
214 // exactly right here.
215 Self::Auth(
216 AuthError::Config(_)
217 | AuthError::Storage(_)
218 | AuthError::Network(_)
219 | AuthError::Server(_)
220 | AuthError::Decode(_)
221 | AuthError::InsecurePermissions { .. },
222 ) => StatusCode::INTERNAL_SERVER_ERROR,
223 }
224 }
225
226 fn description(&self) -> String {
227 match self {
228 Self::Auth(error) => error.to_string(),
229 Self::UnsupportedTokenType => {
230 "access tokens are stateless and cannot be revoked; revoke the refresh token \
231 instead"
232 .to_string()
233 }
234 }
235 }
236
237 fn retry_after_ms(&self) -> Option<u64> {
238 match self {
239 Self::Auth(AuthError::RateLimited { retry_after_ms, .. }) => Some(*retry_after_ms),
240 _ => None,
241 }
242 }
243}
244
245impl IntoResponse for RevocationEndpointError {
246 fn into_response(self) -> Response {
247 oauth_error_response(
248 self.status(),
249 self.oauth_error(),
250 self.description(),
251 self.log_kind(),
252 self.retry_after_ms(),
253 )
254 }
255}
256
257#[cfg(test)]
258#[path = "revoke_tests.rs"]
259mod tests;