headless_lms_server/controllers/main_frontend/oauth/revoke.rs
1use crate::domain::oauth::oauth_validated::OAuthValidated;
2use crate::domain::oauth::revoke_query::RevokeQuery;
3use crate::prelude::*;
4use actix_web::{HttpResponse, web};
5use headless_lms_utils::ApplicationConfiguration;
6use models::{
7 error::ModelErrorType, library::oauth::token_digest_sha256,
8 oauth_access_token::OAuthAccessToken, oauth_client::OAuthClient,
9 oauth_refresh_tokens::OAuthRefreshTokens,
10};
11use sqlx::PgPool;
12
13/// Handles the `/revoke` endpoint for OAuth 2.0 token revocation (RFC 7009).
14///
15/// This endpoint allows clients to revoke access tokens or refresh tokens.
16///
17/// ### Security Features
18/// - Client authentication is required (client_id and client_secret)
19/// - Always returns `200 OK` even for invalid/expired/already-revoked tokens
20/// to prevent token enumeration attacks
21/// - Validates that the token belongs to the authenticated client before revoking
22///
23/// ### Request Parameters
24/// - `token` (required): The token to be revoked
25/// - `token_type_hint` (optional): Hint about token type ("access_token" or "refresh_token")
26///
27/// Follows [RFC 7009 — OAuth 2.0 Token Revocation](https://datatracker.ietf.org/doc/html/rfc7009).
28///
29/// # Example
30/// ```http
31/// POST /api/v0/main-frontend/oauth/revoke HTTP/1.1
32/// Content-Type: application/x-www-form-urlencoded
33///
34/// token=ACCESS_TOKEN_TO_REVOKE&token_type_hint=access_token&client_id=test-client-id&client_secret=test-secret
35/// ```
36///
37/// Response (always 200 OK):
38/// ```http
39/// HTTP/1.1 200 OK
40/// ```
41#[instrument(skip(pool, form, app_conf))]
42pub async fn revoke(
43 pool: web::Data<PgPool>,
44 OAuthValidated(form): OAuthValidated<RevokeQuery>,
45 app_conf: web::Data<ApplicationConfiguration>,
46) -> ControllerResult<HttpResponse> {
47 let mut conn = pool.acquire().await?;
48 let server_token = skip_authorize();
49
50 // Authenticate client
51 // RFC 7009 §2.1: "The authorization server responds with HTTP status code 200 if the token
52 // has been revoked successfully or if the client submitted an invalid token."
53 // This means we should return 200 OK even for invalid client_id/client_secret to prevent
54 // enumeration attacks. However, we still need to validate for legitimate revocations.
55 // RFC 7009 also permits 5xx responses on genuine backend/storage failures.
56 let client_result = OAuthClient::find_by_client_id(&mut conn, &form.client_id).await;
57
58 // Add non-secret fields to the span for observability
59 tracing::Span::current().record("client_id", &form.client_id);
60
61 // Differentiate between "not found" (return 200 OK) and storage failures (return 5xx)
62 let client = match client_result {
63 Ok(c) => c,
64 Err(err) => {
65 match err.error_type() {
66 // Client not found - return 200 OK per RFC 7009 to prevent enumeration
67 ModelErrorType::RecordNotFound | ModelErrorType::NotFound => {
68 return server_token.authorized_ok(HttpResponse::Ok().finish());
69 }
70 // Database/storage failures - return 5xx per RFC 7009
71 _ => {
72 tracing::error!(err = %err, "OAuth revoke: client lookup failed");
73 return Err(ControllerError::new(
74 ControllerErrorType::InternalServerError,
75 "Failed to authenticate client due to storage error".to_string(),
76 Some(err.into()),
77 ));
78 }
79 }
80 }
81 };
82
83 // Validate client secret for confidential clients
84 let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
85 let client_valid = if client.is_confidential() {
86 match &client.client_secret {
87 Some(secret) => {
88 let provided_secret_digest = token_digest_sha256(
89 &form.client_secret.clone().unwrap_or_default(),
90 token_hmac_key,
91 );
92 secret.constant_eq(&provided_secret_digest)
93 }
94 None => false,
95 }
96 } else {
97 true // Public clients don't need secret validation
98 };
99
100 // If client secret is invalid, return 200 OK per RFC 7009 (but don't actually revoke)
101 if !client_valid {
102 return server_token.authorized_ok(HttpResponse::Ok().finish());
103 }
104
105 // Hash the provided token to get digest
106 // We'll recalculate it as needed since Digest doesn't implement Copy
107
108 // Normalize token_type_hint: only recognize "access_token" and "refresh_token",
109 // treat any other value as None (no hint)
110 let hint = form.token_type_hint.as_deref().and_then(|h| {
111 match h {
112 "access_token" | "refresh_token" => Some(h),
113 _ => None, // Unknown hints are ignored
114 }
115 });
116 if let Some(h) = hint {
117 tracing::Span::current().record("token_type_hint", h);
118 }
119
120 // RFC 7009: Try both token types. Attempt the hinted type first (if present),
121 // then always try the other type if the first lookup reports "not found".
122
123 // Try the hinted type first (if hint is present), then try the other type
124 match hint {
125 Some("access_token") => {
126 // Try access token first
127 let token_digest = token_digest_sha256(&form.token, token_hmac_key);
128 let access_token_found = match OAuthAccessToken::find_valid(&mut conn, token_digest)
129 .await
130 {
131 Ok(access_token) => {
132 // Verify the token belongs to the authenticated client before revoking
133 if access_token.client_id == client.id {
134 // Recalculate digest since it was moved
135 let token_digest = token_digest_sha256(&form.token, token_hmac_key);
136 OAuthAccessToken::revoke_by_digest(&mut conn, token_digest).await?;
137 }
138 true
139 }
140 Err(err) => {
141 match err.error_type() {
142 // Token not found - continue to try refresh token
143 ModelErrorType::RecordNotFound | ModelErrorType::NotFound => false,
144 // Database/storage failures - return 5xx per RFC 7009
145 _ => {
146 return Err(ControllerError::new(
147 ControllerErrorType::InternalServerError,
148 "Failed to look up access token due to storage error".to_string(),
149 Some(err.into()),
150 ));
151 }
152 }
153 }
154 };
155 // If not found, try refresh token
156 if !access_token_found {
157 let token_digest = token_digest_sha256(&form.token, token_hmac_key);
158 match OAuthRefreshTokens::find_valid(&mut conn, token_digest).await {
159 Ok(refresh_token) => {
160 // Verify the token belongs to the authenticated client before revoking
161 if refresh_token.client_id == client.id {
162 // Recalculate digest since it was moved
163 let token_digest = token_digest_sha256(&form.token, token_hmac_key);
164 OAuthRefreshTokens::revoke_by_digest(&mut conn, token_digest).await?;
165 }
166 }
167 Err(err) => {
168 match err.error_type() {
169 // Token not found - return 200 OK per RFC 7009
170 ModelErrorType::RecordNotFound | ModelErrorType::NotFound => {
171 // Continue to return 200 OK below
172 }
173 // Database/storage failures - return 5xx per RFC 7009
174 _ => {
175 return Err(ControllerError::new(
176 ControllerErrorType::InternalServerError,
177 "Failed to look up refresh token due to storage error"
178 .to_string(),
179 Some(err.into()),
180 ));
181 }
182 }
183 }
184 }
185 }
186 }
187 Some("refresh_token") => {
188 // Try refresh token first
189 let token_digest = token_digest_sha256(&form.token, token_hmac_key);
190 let refresh_token_found = match OAuthRefreshTokens::find_valid(&mut conn, token_digest)
191 .await
192 {
193 Ok(refresh_token) => {
194 // Verify the token belongs to the authenticated client before revoking
195 if refresh_token.client_id == client.id {
196 // Recalculate digest since it was moved
197 let token_digest = token_digest_sha256(&form.token, token_hmac_key);
198 OAuthRefreshTokens::revoke_by_digest(&mut conn, token_digest).await?;
199 }
200 true
201 }
202 Err(err) => {
203 match err.error_type() {
204 // Token not found - continue to try access token
205 ModelErrorType::RecordNotFound | ModelErrorType::NotFound => false,
206 // Database/storage failures - return 5xx per RFC 7009
207 _ => {
208 return Err(ControllerError::new(
209 ControllerErrorType::InternalServerError,
210 "Failed to look up refresh token due to storage error".to_string(),
211 Some(err.into()),
212 ));
213 }
214 }
215 }
216 };
217 // If not found, try access token
218 if !refresh_token_found {
219 let token_digest = token_digest_sha256(&form.token, token_hmac_key);
220 match OAuthAccessToken::find_valid(&mut conn, token_digest).await {
221 Ok(access_token) => {
222 // Verify the token belongs to the authenticated client before revoking
223 if access_token.client_id == client.id {
224 // Recalculate digest since it was moved
225 let token_digest = token_digest_sha256(&form.token, token_hmac_key);
226 OAuthAccessToken::revoke_by_digest(&mut conn, token_digest).await?;
227 }
228 }
229 Err(err) => {
230 match err.error_type() {
231 // Token not found - return 200 OK per RFC 7009
232 ModelErrorType::RecordNotFound | ModelErrorType::NotFound => {
233 // Continue to return 200 OK below
234 }
235 // Database/storage failures - return 5xx per RFC 7009
236 _ => {
237 return Err(ControllerError::new(
238 ControllerErrorType::InternalServerError,
239 "Failed to look up access token due to storage error"
240 .to_string(),
241 Some(err.into()),
242 ));
243 }
244 }
245 }
246 }
247 }
248 }
249 _ => {
250 // No hint: try access token first, then refresh token
251 let token_digest = token_digest_sha256(&form.token, token_hmac_key);
252 let access_token_found = match OAuthAccessToken::find_valid(&mut conn, token_digest)
253 .await
254 {
255 Ok(access_token) => {
256 // Verify the token belongs to the authenticated client before revoking
257 if access_token.client_id == client.id {
258 // Recalculate digest since it was moved
259 let token_digest = token_digest_sha256(&form.token, token_hmac_key);
260 OAuthAccessToken::revoke_by_digest(&mut conn, token_digest).await?;
261 }
262 true
263 }
264 Err(err) => {
265 match err.error_type() {
266 // Token not found - continue to try refresh token
267 ModelErrorType::RecordNotFound | ModelErrorType::NotFound => false,
268 // Database/storage failures - return 5xx per RFC 7009
269 _ => {
270 return Err(ControllerError::new(
271 ControllerErrorType::InternalServerError,
272 "Failed to look up access token due to storage error".to_string(),
273 Some(err.into()),
274 ));
275 }
276 }
277 }
278 };
279 // If not found, try refresh token
280 if !access_token_found {
281 let token_digest = token_digest_sha256(&form.token, token_hmac_key);
282 match OAuthRefreshTokens::find_valid(&mut conn, token_digest).await {
283 Ok(refresh_token) => {
284 // Verify the token belongs to the authenticated client before revoking
285 if refresh_token.client_id == client.id {
286 // Recalculate digest since it was moved
287 let token_digest = token_digest_sha256(&form.token, token_hmac_key);
288 OAuthRefreshTokens::revoke_by_digest(&mut conn, token_digest).await?;
289 }
290 }
291 Err(err) => {
292 match err.error_type() {
293 // Token not found - return 200 OK per RFC 7009
294 ModelErrorType::RecordNotFound | ModelErrorType::NotFound => {
295 // Continue to return 200 OK below
296 }
297 // Database/storage failures - return 5xx per RFC 7009
298 _ => {
299 return Err(ControllerError::new(
300 ControllerErrorType::InternalServerError,
301 "Failed to look up refresh token due to storage error"
302 .to_string(),
303 Some(err.into()),
304 ));
305 }
306 }
307 }
308 }
309 }
310 }
311 }
312
313 // Always return 200 OK per RFC 7009, even if token was not found or already revoked
314 server_token.authorized_ok(HttpResponse::Ok().finish())
315}
316
317pub fn _add_routes(cfg: &mut web::ServiceConfig) {
318 cfg.route("/revoke", web::post().to(revoke));
319}