Skip to main content

headless_lms_server/controllers/main_frontend/oauth/
userinfo.rs

1use crate::domain::oauth::dpop::verify_dpop_from_actix;
2use crate::domain::oauth::helpers::oauth_invalid_token;
3use crate::domain::oauth::userinfo_response::UserInfoResponse;
4use crate::prelude::*;
5use actix_web::{HttpResponse, guard, web};
6use dpop_verifier::DpopError;
7use headless_lms_base::config::ApplicationConfiguration;
8use models::{
9    library::oauth::{bearer::split_authorization, 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(|| oauth_invalid_token("missing Authorization header"))?;
44
45    let (presented_scheme, raw_token) = match split_authorization(auth) {
46        Some((scheme, token)) if scheme.eq_ignore_ascii_case("DPoP") => (TokenType::DPoP, token),
47        Some((scheme, token)) if scheme.eq_ignore_ascii_case("Bearer") => {
48            (TokenType::Bearer, token)
49        }
50        _ => return Err(oauth_invalid_token("unsupported auth scheme")),
51    };
52
53    let token_hmac_key = &app_conf.oauth_server_configuration.oauth_token_hmac_key;
54    let digest = token_digest_sha256(raw_token, token_hmac_key);
55    let access = OAuthAccessToken::find_valid(&mut conn, digest).await?;
56
57    // Add non-secret fields to the span for observability
58    tracing::Span::current().record("token_type", format!("{:?}", access.token_type));
59    tracing::Span::current().record("client_id", access.client_id.to_string());
60
61    match access.token_type {
62        TokenType::Bearer => {
63            // Bearer tokens must use the Bearer scheme
64            if presented_scheme != TokenType::Bearer {
65                return Err(oauth_invalid_token("bearer token must use Bearer scheme"));
66            }
67
68            let client = OAuthClient::find_by_id(&mut conn, access.client_id).await?;
69            if !client.bearer_allowed {
70                return Err(oauth_invalid_token(
71                    "client not allowed to use bearer tokens",
72                ));
73            }
74        }
75        TokenType::DPoP => {
76            // DPoP-bound tokens must use DPoP scheme + valid proof
77            if presented_scheme != TokenType::DPoP {
78                return Err(oauth_invalid_token("DPoP-bound token must use DPoP scheme"));
79            }
80
81            let bound_jkt = access
82                .dpop_jkt
83                .as_deref()
84                .ok_or_else(|| oauth_invalid_token("token marked DPoP but missing cnf.jkt"))?;
85
86            // Verify proof (includes `ath` = hash of raw_token)
87            let presented_jkt = verify_dpop_from_actix(
88                &mut conn,
89                &req,
90                req.method().as_str(),
91                &app_conf.oauth_server_configuration.dpop_nonce_key,
92                Some(raw_token),
93            )
94            .await?;
95            // Verify JKT (JWK thumbprint) binding: the DPoP proof's key must match the token's bound key
96            // Note: DpopError::AthMismatch is used here because the external dpop_verifier crate
97            // doesn't provide a JktMismatch variant. This check verifies JKT binding, not ATH (access token hash).
98            // TODO: Change this after dpop_verifier is updated to provide a JktMismatch variant.
99            if presented_jkt != bound_jkt {
100                return Err(DpopError::AthMismatch.into());
101            }
102        }
103    }
104
105    let user_id = match access.user_id {
106        Some(u) => u,
107        None => {
108            return Err(oauth_invalid_token("token has no associated user"));
109        }
110    };
111
112    let user = user_details::get_user_details_by_user_id(&mut conn, user_id).await?;
113    let scopes: HashSet<String> = HashSet::from_iter(access.scopes.into_iter());
114
115    let mut res = UserInfoResponse {
116        sub: user_id.to_string(),
117        first_name: None,
118        last_name: None,
119        email: None,
120        email_verified: None,
121    };
122
123    if scopes.contains("profile") {
124        res.first_name = user.first_name.clone();
125        res.last_name = user.last_name.clone();
126    }
127    if scopes.contains("email") {
128        res.email = Some(user.email.clone());
129        res.email_verified = Some(user.email_verified_at.is_some());
130    }
131
132    // Best practice: prevent caching
133    server_token.authorized_ok(
134        HttpResponse::Ok()
135            .insert_header(("Cache-Control", "no-store"))
136            .json(res),
137    )
138}
139
140#[utoipa::path(
141    get,
142    path = "/userinfo",
143    operation_id = "getOauthUserInfo",
144    tag = "oauth",
145    responses(
146        (status = 200, description = "OAuth userinfo response", body = serde_json::Value),
147        (status = 401, description = "Invalid token")
148    )
149)]
150#[allow(dead_code)]
151pub(crate) fn user_info_get_doc() {}
152
153#[utoipa::path(
154    post,
155    path = "/userinfo",
156    operation_id = "postOauthUserInfo",
157    tag = "oauth",
158    responses(
159        (status = 200, description = "OAuth userinfo response", body = serde_json::Value),
160        (status = 401, description = "Invalid token")
161    )
162)]
163#[allow(dead_code)]
164pub(crate) fn user_info_post_doc() {}
165
166pub fn _add_routes(cfg: &mut web::ServiceConfig) {
167    cfg.route(
168        "/userinfo",
169        web::route()
170            .guard(guard::Any(guard::Get()).or(guard::Post()))
171            .to(user_info),
172    );
173}