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}