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
6/// An OAuth error response (RFC 6749 §4.1.2.1, §5.2). With `redirect`, which must be a redirect URI
7/// verified for the client, it is sent to the client as a redirect; otherwise it is answered as
8/// JSON.
9#[track_caller]
10pub fn oauth_error(
11    code: OAuthErrorCode,
12    description: impl Into<String>,
13    redirect: Option<&str>,
14    state: Option<&str>,
15) -> ControllerError {
16    let description = description.into();
17    controller_err!(
18        OAuthError(Box::new(OAuthErrorData {
19            redirect_uri: redirect.map(str::to_string),
20            state: state.map(str::to_string),
21            ..OAuthErrorData::new(code, description.clone())
22        })),
23        description
24    )
25}
26
27/// A `server_error` answered as JSON, keeping the internal cause for the logs.
28#[track_caller]
29pub fn oauth_server_error(description: &'static str, source: anyhow::Error) -> ControllerError {
30    controller_err!(
31        OAuthError(Box::new(OAuthErrorData::new(
32            OAuthErrorCode::ServerError,
33            description,
34        ))),
35        description,
36        source
37    )
38}
39
40#[track_caller]
41pub fn oauth_invalid_request(
42    description: impl Into<String>,
43    redirect: Option<&str>,
44    state: Option<&str>,
45) -> ControllerError {
46    oauth_error(OAuthErrorCode::InvalidRequest, description, redirect, state)
47}
48
49/// `invalid_client`: the client could not be authenticated.
50#[track_caller]
51pub fn oauth_invalid_client(description: impl Into<String>) -> ControllerError {
52    oauth_error(OAuthErrorCode::InvalidClient, description, None, None)
53}
54
55/// `invalid_token` for a request to a protected resource (RFC 6750 §3.1).
56#[track_caller]
57pub fn oauth_invalid_token(description: impl Into<String>) -> ControllerError {
58    oauth_error(OAuthErrorCode::InvalidToken, description, None, None)
59}
60
61/// `invalid_scope`: the requested scope is invalid, unknown or exceeds what is allowed.
62#[track_caller]
63pub fn oauth_invalid_scope(description: impl Into<String>) -> ControllerError {
64    oauth_error(OAuthErrorCode::InvalidScope, description, None, None)
65}
66
67/// `unauthorized_client`: the client may not use this grant type.
68#[track_caller]
69pub fn oauth_unauthorized_client(description: impl Into<String>) -> ControllerError {
70    oauth_error(OAuthErrorCode::UnauthorizedClient, description, None, None)
71}
72
73/// `invalid_grant`: the authorization code, refresh token or device code is invalid, expired or revoked.
74#[track_caller]
75pub fn oauth_invalid_grant(description: impl Into<String>) -> ControllerError {
76    oauth_error(OAuthErrorCode::InvalidGrant, description, None, None)
77}
78
79pub fn scope_has_openid(scope: &[String]) -> bool {
80    scope.iter().any(|s| s == "openid")
81}
82
83/// Splits a space-delimited `scope` string and checks every element against `allowed`.
84///
85/// Returns the parsed scopes, or the first requested scope not present in `allowed`.
86/// Callers decide how to map an absent/empty request and how to report a rejection.
87pub fn split_and_validate_scopes(
88    requested: &str,
89    allowed: &[String],
90) -> Result<Vec<String>, String> {
91    let scopes: Vec<String> = requested
92        .split_whitespace()
93        .map(|s| s.to_string())
94        .collect();
95    for scope in &scopes {
96        if !allowed.contains(scope) {
97            return Err(scope.clone());
98        }
99    }
100    Ok(scopes)
101}
102
103pub fn ok_json_no_cache<T: Serialize>(value: T) -> HttpResponse {
104    let mut resp = HttpResponse::Ok();
105    resp.insert_header(("Cache-Control", "no-store"));
106    resp.insert_header(("Pragma", "no-cache"));
107    resp.json(value)
108}
109
110/// Why a client failed to authenticate against its `client_id`/`client_secret`.
111pub enum ClientAuthError {
112    UnknownClient,
113    ClientSecretMissing,
114    ClientSecretMismatch,
115}
116
117/// Looks up a client by `client_id` and, if it is confidential, verifies `provided_secret`
118/// against its stored digest with a constant-time comparison.
119///
120/// A public client is returned without any secret check: callers that must reject public
121/// clients outright (e.g. introspection) do so themselves once this returns.
122pub async fn authenticate_oauth_client(
123    conn: &mut PgConnection,
124    client_id: &str,
125    provided_secret: Option<&SecretString>,
126    token_hmac_key: &SecretString,
127) -> Result<OAuthClient, ClientAuthError> {
128    let client = OAuthClient::find_by_client_id(conn, client_id)
129        .await
130        .map_err(|e| {
131            tracing::error!(err = %e, "OAuth: unknown client_id");
132            ClientAuthError::UnknownClient
133        })?;
134
135    if !client.is_confidential() {
136        return Ok(client);
137    }
138
139    let Some(secret) = &client.client_secret else {
140        return Err(ClientAuthError::ClientSecretMissing);
141    };
142    let provided = token_digest_sha256(
143        provided_secret
144            .map(|s| s.expose_secret())
145            .unwrap_or_default(),
146        token_hmac_key,
147    );
148    if !secret.constant_eq(&provided) {
149        return Err(ClientAuthError::ClientSecretMismatch);
150    }
151
152    Ok(client)
153}