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