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}