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}