headless_lms_server/controllers/main_frontend/oauth/
userinfo.rs

1use crate::domain::oauth::dpop::verify_dpop_from_actix;
2use crate::domain::oauth::userinfo_response::UserInfoResponse;
3use crate::prelude::*;
4use actix_web::{HttpResponse, web};
5use domain::error::{OAuthErrorCode, OAuthErrorData};
6use dpop_verifier::DpopError;
7use headless_lms_utils::ApplicationConfiguration;
8use models::{
9    library::oauth::token_digest_sha256,
10    oauth_access_token::{OAuthAccessToken, TokenType},
11    oauth_client::OAuthClient,
12    user_details,
13};
14use std::collections::HashSet;
15
16/// Handles `/userinfo` for returning user claims according to granted scopes.
17///
18/// - Validates access token (Bearer or DPoP-bound)
19/// - For DPoP tokens: requires valid DPoP proof (JKT + ATH)
20/// - For Bearer tokens: requires client.bearer_allowed = true
21/// - Returns `sub` always; `first_name`/`last_name` with `profile`; `email` with `email`
22///
23/// Follows OIDC Core ยง5.3.
24#[instrument(skip(pool, app_conf, req))]
25pub async fn user_info(
26    pool: web::Data<sqlx::PgPool>,
27    req: actix_web::HttpRequest,
28    app_conf: web::Data<ApplicationConfiguration>,
29) -> ControllerResult<HttpResponse> {
30    let mut conn = pool.acquire().await?;
31    let server_token = skip_authorize();
32
33    let auth = req
34        .headers()
35        .get("Authorization")
36        .and_then(|v| v.to_str().ok())
37        .ok_or_else(|| {
38            ControllerError::new(
39                ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
40                    error: OAuthErrorCode::InvalidToken.as_str().into(),
41                    error_description: "missing Authorization header".into(),
42                    redirect_uri: None,
43                    state: None,
44                    nonce: None,
45                })),
46                "missing Authorization header",
47                None::<anyhow::Error>,
48            )
49        })?;
50
51    let (presented_scheme, raw_token) = if let Some(t) = auth.strip_prefix("DPoP ") {
52        ("DPoP", t)
53    } else if let Some(t) = auth.strip_prefix("Bearer ") {
54        ("Bearer", t)
55    } else {
56        return Err(ControllerError::new(
57            ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
58                error: OAuthErrorCode::InvalidToken.as_str().into(),
59                error_description: "unsupported auth scheme".into(),
60                redirect_uri: None,
61                state: None,
62                nonce: None,
63            })),
64            "unsupported auth scheme",
65            None::<anyhow::Error>,
66        ));
67    };
68
69    let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
70    let digest = token_digest_sha256(raw_token, token_hmac_key);
71    let access = OAuthAccessToken::find_valid(&mut conn, digest).await?;
72
73    // Add non-secret fields to the span for observability
74    tracing::Span::current().record("token_type", format!("{:?}", access.token_type));
75    tracing::Span::current().record("client_id", access.client_id.to_string());
76
77    match access.token_type {
78        TokenType::Bearer => {
79            // Bearer tokens must use the Bearer scheme
80            if presented_scheme != "Bearer" {
81                return Err(ControllerError::new(
82                    ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
83                        error: OAuthErrorCode::InvalidToken.as_str().into(),
84                        error_description: "bearer token must use Bearer scheme".into(),
85                        redirect_uri: None,
86                        state: None,
87                        nonce: None,
88                    })),
89                    "wrong auth scheme for bearer token",
90                    None::<anyhow::Error>,
91                ));
92            }
93
94            let client = OAuthClient::find_by_id(&mut conn, access.client_id).await?;
95            if !client.bearer_allowed {
96                return Err(ControllerError::new(
97                    ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
98                        error: OAuthErrorCode::InvalidToken.as_str().into(),
99                        error_description: "client not allowed to use bearer tokens".into(),
100                        redirect_uri: None,
101                        state: None,
102                        nonce: None,
103                    })),
104                    "client not bearer-allowed",
105                    None::<anyhow::Error>,
106                ));
107            }
108        }
109        TokenType::DPoP => {
110            // DPoP-bound tokens must use DPoP scheme + valid proof
111            if presented_scheme != "DPoP" {
112                return Err(ControllerError::new(
113                    ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
114                        error: OAuthErrorCode::InvalidToken.as_str().into(),
115                        error_description: "DPoP-bound token must use DPoP scheme".into(),
116                        redirect_uri: None,
117                        state: None,
118                        nonce: None,
119                    })),
120                    "wrong auth scheme for DPoP token",
121                    None::<anyhow::Error>,
122                ));
123            }
124
125            let bound_jkt = access.dpop_jkt.as_deref().ok_or_else(|| {
126                ControllerError::new(
127                    ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
128                        error: OAuthErrorCode::InvalidToken.as_str().into(),
129                        error_description: "token marked DPoP but missing cnf.jkt".into(),
130                        redirect_uri: None,
131                        state: None,
132                        nonce: None,
133                    })),
134                    "dpop token missing jkt",
135                    None::<anyhow::Error>,
136                )
137            })?;
138
139            // Verify proof (includes `ath` = hash of raw_token)
140            let presented_jkt = verify_dpop_from_actix(
141                &mut conn,
142                &req,
143                "GET",
144                &app_conf.oauth_server_configuration.dpop_nonce_key,
145                Some(raw_token),
146            )
147            .await?;
148            // Verify JKT (JWK thumbprint) binding: the DPoP proof's key must match the token's bound key
149            // Note: DpopError::AthMismatch is used here because the external dpop_verifier crate
150            // doesn't provide a JktMismatch variant. This check verifies JKT binding, not ATH (access token hash).
151            // TODO: Change this after dpop_verifier is updated to provide a JktMismatch variant.
152            if presented_jkt != bound_jkt {
153                return Err(DpopError::AthMismatch.into());
154            }
155        }
156    }
157
158    let user_id = match access.user_id {
159        Some(u) => u,
160        None => {
161            return Err(ControllerError::new(
162                ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
163                    error: OAuthErrorCode::InvalidToken.as_str().into(),
164                    error_description: "token has no associated user".into(),
165                    redirect_uri: None,
166                    state: None,
167                    nonce: None,
168                })),
169                "token has no associated user",
170                None::<anyhow::Error>,
171            ));
172        }
173    };
174
175    let user = user_details::get_user_details_by_user_id(&mut conn, user_id).await?;
176    let scopes: HashSet<String> = HashSet::from_iter(access.scopes.into_iter());
177
178    let mut res = UserInfoResponse {
179        sub: user_id.to_string(),
180        first_name: None,
181        last_name: None,
182        email: None,
183    };
184
185    if scopes.contains("profile") {
186        res.first_name = user.first_name.clone();
187        res.last_name = user.last_name.clone();
188    }
189    if scopes.contains("email") {
190        res.email = Some(user.email.clone());
191    }
192
193    // Best practice: prevent caching
194    server_token.authorized_ok(
195        HttpResponse::Ok()
196            .insert_header(("Cache-Control", "no-store"))
197            .json(res),
198    )
199}
200
201pub fn _add_routes(cfg: &mut web::ServiceConfig) {
202    cfg.route("/userinfo", web::get().to(user_info));
203}