Skip to main content

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;