Skip to main content

headless_lms_server/controllers/main_frontend/oauth/
revoke.rs

1use crate::domain::exercise_services::token::{invalidate_cached_user, invalidate_cached_users};
2use crate::domain::oauth::oauth_validated::OAuthValidated;
3use crate::domain::oauth::revoke_query::RevokeQuery;
4use crate::prelude::*;
5use actix_web::{HttpResponse, web};
6use headless_lms_base::config::ApplicationConfiguration;
7use headless_lms_utils::cache::Cache;
8use models::{
9    error::ModelErrorType, library::oauth::token_digest_sha256,
10    oauth_access_token::OAuthAccessToken, oauth_client::OAuthClient,
11    oauth_refresh_tokens::OAuthRefreshTokens,
12};
13use secrecy::ExposeSecret;
14use sqlx::PgPool;
15use utoipa::OpenApi;
16
17#[derive(OpenApi)]
18#[openapi(paths(revoke))]
19#[allow(dead_code)]
20pub(crate) struct MainFrontendOauthRevokeApiDoc;
21
22/// Handles the `/revoke` endpoint for OAuth 2.0 token revocation (RFC 7009).
23///
24/// This endpoint allows clients to revoke access tokens or refresh tokens.
25///
26/// ### Security Features
27/// - Client authentication is required (client_id and client_secret)
28/// - Always returns `200 OK` even for invalid/expired/already-revoked tokens
29///   to prevent token enumeration attacks
30/// - Validates that the token belongs to the authenticated client before revoking
31///
32/// ### Request Parameters
33/// - `token` (required): The token to be revoked
34/// - `token_type_hint` (optional): Hint about token type ("access_token" or "refresh_token")
35///
36/// Follows [RFC 7009 — OAuth 2.0 Token Revocation](https://datatracker.ietf.org/doc/html/rfc7009).
37///
38/// # Example
39/// ```http
40/// POST /api/v0/main-frontend/oauth/revoke HTTP/1.1
41/// Content-Type: application/x-www-form-urlencoded
42///
43/// token=ACCESS_TOKEN_TO_REVOKE&token_type_hint=access_token&client_id=test-client-id&client_secret=test-secret
44/// ```
45///
46/// Response (always 200 OK):
47/// ```http
48/// HTTP/1.1 200 OK
49/// ```
50#[instrument(skip(pool, form, app_conf, cache))]
51#[utoipa::path(
52    post,
53    path = "/revoke",
54    operation_id = "revokeOauthToken",
55    tag = "oauth",
56    request_body(
57        content = serde_json::Value,
58        content_type = "application/x-www-form-urlencoded"
59    ),
60    responses(
61        (status = 200, description = "OAuth token revocation acknowledged")
62    )
63)]
64pub async fn revoke(
65    pool: web::Data<PgPool>,
66    OAuthValidated(form): OAuthValidated<RevokeQuery>,
67    app_conf: web::Data<ApplicationConfiguration>,
68    cache: web::Data<Cache>,
69) -> ControllerResult<HttpResponse> {
70    let mut conn = pool.acquire().await?;
71    let server_token = skip_authorize();
72
73    // Authenticate client
74    // RFC 7009 §2.1: "The authorization server responds with HTTP status code 200 if the token
75    // has been revoked successfully or if the client submitted an invalid token."
76    // This means we should return 200 OK even for invalid client_id/client_secret to prevent
77    // enumeration attacks. However, we still need to validate for legitimate revocations.
78    // RFC 7009 also permits 5xx responses on genuine backend/storage failures.
79    let client_result = OAuthClient::find_by_client_id(&mut conn, &form.client_id).await;
80
81    // Add non-secret fields to the span for observability
82    tracing::Span::current().record("client_id", &form.client_id);
83
84    // Differentiate between "not found" (return 200 OK) and storage failures (return 5xx)
85    let client = match client_result {
86        Ok(c) => c,
87        Err(err) => {
88            match err.error_type() {
89                // Client not found - return 200 OK per RFC 7009 to prevent enumeration
90                ModelErrorType::RecordNotFound | ModelErrorType::NotFound => {
91                    return server_token.authorized_ok(HttpResponse::Ok().finish());
92                }
93                // Database/storage failures - return 5xx per RFC 7009
94                _ => {
95                    tracing::error!(err = %err, "OAuth revoke: client lookup failed");
96                    return Err(ControllerError::new(
97                        ControllerErrorType::InternalServerError,
98                        "Failed to authenticate client due to storage error".to_string(),
99                        Some(err.into()),
100                    ));
101                }
102            }
103        }
104    };
105
106    // Validate client secret for confidential clients
107    let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
108    let client_valid = if client.is_confidential() {
109        match &client.client_secret {
110            Some(secret) => {
111                let provided_secret_digest = token_digest_sha256(
112                    form.client_secret
113                        .as_ref()
114                        .map(|s| s.expose_secret())
115                        .unwrap_or_default(),
116                    token_hmac_key,
117                );
118                secret.constant_eq(&provided_secret_digest)
119            }
120            None => false,
121        }
122    } else {
123        true // Public clients don't need secret validation
124    };
125
126    // If client secret is invalid, return 200 OK per RFC 7009 (but don't actually revoke)
127    if !client_valid {
128        return server_token.authorized_ok(HttpResponse::Ok().finish());
129    }
130
131    // Hash the provided token to get digest
132    // We'll recalculate it as needed since Digest doesn't implement Copy
133
134    // Normalize token_type_hint: only recognize "access_token" and "refresh_token",
135    // treat any other value as None (no hint)
136    let hint = form.token_type_hint.as_deref().and_then(|h| {
137        match h {
138            "access_token" | "refresh_token" => Some(h),
139            _ => None, // Unknown hints are ignored
140        }
141    });
142    if let Some(h) = hint {
143        tracing::Span::current().record("token_type_hint", h);
144    }
145
146    // RFC 7009: try the hinted token type first, then the other one if the first lookup found
147    // nothing.
148    if hint == Some("refresh_token") {
149        if !revoke_refresh_grant_of_client(&mut conn, &form, &client, token_hmac_key, &cache)
150            .await?
151        {
152            revoke_access_token_of_client(&mut conn, &form, &client, token_hmac_key, &cache)
153                .await?;
154        }
155    } else if !revoke_access_token_of_client(&mut conn, &form, &client, token_hmac_key, &cache)
156        .await?
157    {
158        revoke_refresh_grant_of_client(&mut conn, &form, &client, token_hmac_key, &cache).await?;
159    }
160
161    // Always return 200 OK per RFC 7009, even if token was not found or already revoked
162    server_token.authorized_ok(HttpResponse::Ok().finish())
163}
164
165/// Classifies a token lookup failure: `Ok(())` for "no such live token", so the caller can try the
166/// other token type, and 5xx for a storage failure (RFC 7009 permits 5xx on genuine backend
167/// failures, but not on an unknown token).
168fn not_found_or_storage_error(err: models::ModelError, what: &str) -> Result<(), ControllerError> {
169    match err.error_type() {
170        ModelErrorType::RecordNotFound | ModelErrorType::NotFound => Ok(()),
171        _ => Err(controller_err!(
172            InternalServerError,
173            format!("Failed to look up {what} due to storage error"),
174            err
175        )),
176    }
177}
178
179/// Deletes the presented access token if it belongs to the authenticated client, and evicts its
180/// cached user mapping so it cannot keep authenticating from a stale cache hit.
181///
182/// `Ok(false)` means no live access token has that digest.
183async fn revoke_access_token_of_client(
184    conn: &mut sqlx::PgConnection,
185    form: &crate::domain::oauth::revoke_query::RevokeParams,
186    client: &OAuthClient,
187    token_hmac_key: &secrecy::SecretString,
188    cache: &Cache,
189) -> Result<bool, ControllerError> {
190    let digest = token_digest_sha256(form.token.expose_secret(), token_hmac_key);
191    match OAuthAccessToken::find_valid(conn, digest).await {
192        Ok(access_token) => {
193            if access_token.client_id == client.id {
194                let digest = token_digest_sha256(form.token.expose_secret(), token_hmac_key);
195                OAuthAccessToken::revoke_by_digest(conn, digest).await?;
196                let digest = token_digest_sha256(form.token.expose_secret(), token_hmac_key);
197                invalidate_cached_user(cache, &digest, token_hmac_key).await;
198            }
199            Ok(true)
200        }
201        Err(err) => not_found_or_storage_error(err, "access token").map(|_| false),
202    }
203}
204
205/// Revokes the presented refresh token if it belongs to the authenticated client, together with
206/// everything else issued from the same (user, client) grant.
207///
208/// RFC 7009 §2.1: the authorization server SHOULD revoke all tokens issued from the same grant.
209/// Revoking only the refresh-token row would leave the paired access token authenticating the
210/// exercise-services client API — from the Redis cache even after the row is gone — for its full
211/// remaining lifetime, so "log out" would not log the user out.
212///
213/// `Ok(false)` means no live refresh token has that digest.
214async fn revoke_refresh_grant_of_client(
215    conn: &mut sqlx::PgConnection,
216    form: &crate::domain::oauth::revoke_query::RevokeParams,
217    client: &OAuthClient,
218    token_hmac_key: &secrecy::SecretString,
219    cache: &Cache,
220) -> Result<bool, ControllerError> {
221    let digest = token_digest_sha256(form.token.expose_secret(), token_hmac_key);
222    match OAuthRefreshTokens::find_valid(conn, digest).await {
223        Ok(refresh_token) => {
224            if refresh_token.client_id == client.id {
225                let revoked_access_digests =
226                    OAuthRefreshTokens::revoke_grant(conn, refresh_token.user_id, client.id)
227                        .await?;
228                invalidate_cached_users(cache, &revoked_access_digests, token_hmac_key).await;
229            }
230            Ok(true)
231        }
232        Err(err) => not_found_or_storage_error(err, "refresh token").map(|_| false),
233    }
234}
235
236pub fn _add_routes(cfg: &mut web::ServiceConfig) {
237    cfg.route("/revoke", web::post().to(revoke));
238}