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