Skip to main content

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