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                    return Err(ControllerError::new(
73                        ControllerErrorType::InternalServerError,
74                        "Failed to authenticate client due to storage error".to_string(),
75                        Some(err.into()),
76                    ));
77                }
78            }
79        }
80    };
81
82    // Validate client secret for confidential clients
83    let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
84    let client_valid = if client.is_confidential() {
85        match &client.client_secret {
86            Some(secret) => {
87                let provided_secret_digest = token_digest_sha256(
88                    &form.client_secret.clone().unwrap_or_default(),
89                    token_hmac_key,
90                );
91                secret.constant_eq(&provided_secret_digest)
92            }
93            None => false,
94        }
95    } else {
96        true // Public clients don't need secret validation
97    };
98
99    // If client secret is invalid, return 200 OK per RFC 7009 (but don't actually revoke)
100    if !client_valid {
101        return server_token.authorized_ok(HttpResponse::Ok().finish());
102    }
103
104    // Hash the provided token to get digest
105    // We'll recalculate it as needed since Digest doesn't implement Copy
106
107    // Normalize token_type_hint: only recognize "access_token" and "refresh_token",
108    // treat any other value as None (no hint)
109    let hint = form.token_type_hint.as_deref().and_then(|h| {
110        match h {
111            "access_token" | "refresh_token" => Some(h),
112            _ => None, // Unknown hints are ignored
113        }
114    });
115    if let Some(h) = hint {
116        tracing::Span::current().record("token_type_hint", h);
117    }
118
119    // RFC 7009: Try both token types. Attempt the hinted type first (if present),
120    // then always try the other type if the first lookup reports "not found".
121
122    // Try the hinted type first (if hint is present), then try the other type
123    match hint {
124        Some("access_token") => {
125            // Try access token first
126            let token_digest = token_digest_sha256(&form.token, token_hmac_key);
127            let access_token_found = match OAuthAccessToken::find_valid(&mut conn, token_digest)
128                .await
129            {
130                Ok(access_token) => {
131                    // Verify the token belongs to the authenticated client before revoking
132                    if access_token.client_id == client.id {
133                        // Recalculate digest since it was moved
134                        let token_digest = token_digest_sha256(&form.token, token_hmac_key);
135                        OAuthAccessToken::revoke_by_digest(&mut conn, token_digest).await?;
136                    }
137                    true
138                }
139                Err(err) => {
140                    match err.error_type() {
141                        // Token not found - continue to try refresh token
142                        ModelErrorType::RecordNotFound | ModelErrorType::NotFound => false,
143                        // Database/storage failures - return 5xx per RFC 7009
144                        _ => {
145                            return Err(ControllerError::new(
146                                ControllerErrorType::InternalServerError,
147                                "Failed to look up access token due to storage error".to_string(),
148                                Some(err.into()),
149                            ));
150                        }
151                    }
152                }
153            };
154            // If not found, try refresh token
155            if !access_token_found {
156                let token_digest = token_digest_sha256(&form.token, token_hmac_key);
157                match OAuthRefreshTokens::find_valid(&mut conn, token_digest).await {
158                    Ok(refresh_token) => {
159                        // Verify the token belongs to the authenticated client before revoking
160                        if refresh_token.client_id == client.id {
161                            // Recalculate digest since it was moved
162                            let token_digest = token_digest_sha256(&form.token, token_hmac_key);
163                            OAuthRefreshTokens::revoke_by_digest(&mut conn, token_digest).await?;
164                        }
165                    }
166                    Err(err) => {
167                        match err.error_type() {
168                            // Token not found - return 200 OK per RFC 7009
169                            ModelErrorType::RecordNotFound | ModelErrorType::NotFound => {
170                                // Continue to return 200 OK below
171                            }
172                            // Database/storage failures - return 5xx per RFC 7009
173                            _ => {
174                                return Err(ControllerError::new(
175                                    ControllerErrorType::InternalServerError,
176                                    "Failed to look up refresh token due to storage error"
177                                        .to_string(),
178                                    Some(err.into()),
179                                ));
180                            }
181                        }
182                    }
183                }
184            }
185        }
186        Some("refresh_token") => {
187            // Try refresh token first
188            let token_digest = token_digest_sha256(&form.token, token_hmac_key);
189            let refresh_token_found = match OAuthRefreshTokens::find_valid(&mut conn, token_digest)
190                .await
191            {
192                Ok(refresh_token) => {
193                    // Verify the token belongs to the authenticated client before revoking
194                    if refresh_token.client_id == client.id {
195                        // Recalculate digest since it was moved
196                        let token_digest = token_digest_sha256(&form.token, token_hmac_key);
197                        OAuthRefreshTokens::revoke_by_digest(&mut conn, token_digest).await?;
198                    }
199                    true
200                }
201                Err(err) => {
202                    match err.error_type() {
203                        // Token not found - continue to try access token
204                        ModelErrorType::RecordNotFound | ModelErrorType::NotFound => false,
205                        // Database/storage failures - return 5xx per RFC 7009
206                        _ => {
207                            return Err(ControllerError::new(
208                                ControllerErrorType::InternalServerError,
209                                "Failed to look up refresh token due to storage error".to_string(),
210                                Some(err.into()),
211                            ));
212                        }
213                    }
214                }
215            };
216            // If not found, try access token
217            if !refresh_token_found {
218                let token_digest = token_digest_sha256(&form.token, token_hmac_key);
219                match OAuthAccessToken::find_valid(&mut conn, token_digest).await {
220                    Ok(access_token) => {
221                        // Verify the token belongs to the authenticated client before revoking
222                        if access_token.client_id == client.id {
223                            // Recalculate digest since it was moved
224                            let token_digest = token_digest_sha256(&form.token, token_hmac_key);
225                            OAuthAccessToken::revoke_by_digest(&mut conn, token_digest).await?;
226                        }
227                    }
228                    Err(err) => {
229                        match err.error_type() {
230                            // Token not found - return 200 OK per RFC 7009
231                            ModelErrorType::RecordNotFound | ModelErrorType::NotFound => {
232                                // Continue to return 200 OK below
233                            }
234                            // Database/storage failures - return 5xx per RFC 7009
235                            _ => {
236                                return Err(ControllerError::new(
237                                    ControllerErrorType::InternalServerError,
238                                    "Failed to look up access token due to storage error"
239                                        .to_string(),
240                                    Some(err.into()),
241                                ));
242                            }
243                        }
244                    }
245                }
246            }
247        }
248        _ => {
249            // No hint: try access token first, then refresh token
250            let token_digest = token_digest_sha256(&form.token, token_hmac_key);
251            let access_token_found = match OAuthAccessToken::find_valid(&mut conn, token_digest)
252                .await
253            {
254                Ok(access_token) => {
255                    // Verify the token belongs to the authenticated client before revoking
256                    if access_token.client_id == client.id {
257                        // Recalculate digest since it was moved
258                        let token_digest = token_digest_sha256(&form.token, token_hmac_key);
259                        OAuthAccessToken::revoke_by_digest(&mut conn, token_digest).await?;
260                    }
261                    true
262                }
263                Err(err) => {
264                    match err.error_type() {
265                        // Token not found - continue to try refresh token
266                        ModelErrorType::RecordNotFound | ModelErrorType::NotFound => false,
267                        // Database/storage failures - return 5xx per RFC 7009
268                        _ => {
269                            return Err(ControllerError::new(
270                                ControllerErrorType::InternalServerError,
271                                "Failed to look up access token due to storage error".to_string(),
272                                Some(err.into()),
273                            ));
274                        }
275                    }
276                }
277            };
278            // If not found, try refresh token
279            if !access_token_found {
280                let token_digest = token_digest_sha256(&form.token, token_hmac_key);
281                match OAuthRefreshTokens::find_valid(&mut conn, token_digest).await {
282                    Ok(refresh_token) => {
283                        // Verify the token belongs to the authenticated client before revoking
284                        if refresh_token.client_id == client.id {
285                            // Recalculate digest since it was moved
286                            let token_digest = token_digest_sha256(&form.token, token_hmac_key);
287                            OAuthRefreshTokens::revoke_by_digest(&mut conn, token_digest).await?;
288                        }
289                    }
290                    Err(err) => {
291                        match err.error_type() {
292                            // Token not found - return 200 OK per RFC 7009
293                            ModelErrorType::RecordNotFound | ModelErrorType::NotFound => {
294                                // Continue to return 200 OK below
295                            }
296                            // Database/storage failures - return 5xx per RFC 7009
297                            _ => {
298                                return Err(ControllerError::new(
299                                    ControllerErrorType::InternalServerError,
300                                    "Failed to look up refresh token due to storage error"
301                                        .to_string(),
302                                    Some(err.into()),
303                                ));
304                            }
305                        }
306                    }
307                }
308            }
309        }
310    }
311
312    // Always return 200 OK per RFC 7009, even if token was not found or already revoked
313    server_token.authorized_ok(HttpResponse::Ok().finish())
314}
315
316pub fn _add_routes(cfg: &mut web::ServiceConfig) {
317    cfg.route("/revoke", web::post().to(revoke));
318}