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, 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#[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 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 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 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 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 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 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}