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