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}