headless_lms_server/controllers/main_frontend/oauth/
userinfo.rs1use 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#[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 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 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 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 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 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 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}