Skip to main content

headless_lms_server/domain/oauth/
helpers.rs

1use crate::domain::error::{OAuthErrorCode, OAuthErrorData};
2use crate::prelude::*;
3use models::{library::oauth::token_digest_sha256, oauth_client::OAuthClient};
4use secrecy::{ExposeSecret, SecretString};
5
6pub fn oauth_error(
7    error: &'static str,
8    desc: &'static str,
9    redirect: Option<&str>,
10    state: Option<&str>,
11) -> ControllerError {
12    ControllerError::new(
13        ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
14            error: error.into(),
15            error_description: desc.into(),
16            redirect_uri: redirect.map(str::to_string),
17            state: state.map(str::to_string),
18            nonce: None,
19        })),
20        desc,
21        None::<anyhow::Error>,
22    )
23}
24
25pub fn oauth_invalid_request(
26    desc: &'static str,
27    redirect: Option<&str>,
28    state: Option<&str>,
29) -> ControllerError {
30    oauth_error(
31        OAuthErrorCode::InvalidRequest.as_str(),
32        desc,
33        redirect,
34        state,
35    )
36}
37
38pub fn oauth_invalid_client(desc: &'static str) -> ControllerError {
39    oauth_error(OAuthErrorCode::InvalidClient.as_str(), desc, None, None)
40}
41
42pub fn oauth_invalid_scope(desc: &'static str) -> ControllerError {
43    oauth_error(OAuthErrorCode::InvalidScope.as_str(), desc, None, None)
44}
45
46pub fn oauth_unauthorized_client(desc: &'static str) -> ControllerError {
47    oauth_error(
48        OAuthErrorCode::UnauthorizedClient.as_str(),
49        desc,
50        None,
51        None,
52    )
53}
54
55pub fn oauth_invalid_grant(desc: &'static str) -> ControllerError {
56    oauth_error(OAuthErrorCode::InvalidGrant.as_str(), desc, None, None)
57}
58
59pub fn scope_has_openid(scope: &[String]) -> bool {
60    scope.iter().any(|s| s == "openid")
61}
62
63/// Splits a space-delimited `scope` string and checks every element against `allowed`.
64///
65/// Returns the parsed scopes, or the first requested scope not present in `allowed`.
66/// Callers decide how to map an absent/empty request and how to report a rejection.
67pub fn split_and_validate_scopes(
68    requested: &str,
69    allowed: &[String],
70) -> Result<Vec<String>, String> {
71    let scopes: Vec<String> = requested
72        .split_whitespace()
73        .map(|s| s.to_string())
74        .collect();
75    for scope in &scopes {
76        if !allowed.contains(scope) {
77            return Err(scope.clone());
78        }
79    }
80    Ok(scopes)
81}
82
83pub fn ok_json_no_cache<T: Serialize>(value: T) -> HttpResponse {
84    let mut resp = HttpResponse::Ok();
85    resp.insert_header(("Cache-Control", "no-store"));
86    resp.insert_header(("Pragma", "no-cache"));
87    resp.json(value)
88}
89
90/// Why a client failed to authenticate against its `client_id`/`client_secret`.
91pub enum ClientAuthError {
92    UnknownClient,
93    ClientSecretMissing,
94    ClientSecretMismatch,
95}
96
97/// Looks up a client by `client_id` and, if it is confidential, verifies `provided_secret`
98/// against its stored digest with a constant-time comparison.
99///
100/// A public client is returned without any secret check: callers that must reject public
101/// clients outright (e.g. introspection) do so themselves once this returns.
102pub async fn authenticate_oauth_client(
103    conn: &mut PgConnection,
104    client_id: &str,
105    provided_secret: Option<&SecretString>,
106    token_hmac_key: &SecretString,
107) -> Result<OAuthClient, ClientAuthError> {
108    let client = OAuthClient::find_by_client_id(conn, client_id)
109        .await
110        .map_err(|e| {
111            tracing::error!(err = %e, "OAuth: unknown client_id");
112            ClientAuthError::UnknownClient
113        })?;
114
115    if !client.is_confidential() {
116        return Ok(client);
117    }
118
119    let Some(secret) = &client.client_secret else {
120        return Err(ClientAuthError::ClientSecretMissing);
121    };
122    let provided = token_digest_sha256(
123        provided_secret
124            .map(|s| s.expose_secret())
125            .unwrap_or_default(),
126        token_hmac_key,
127    );
128    if !secret.constant_eq(&provided) {
129        return Err(ClientAuthError::ClientSecretMismatch);
130    }
131
132    Ok(client)
133}