1use axum::Router;
2use axum::extract::Extension;
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use axum::routing::get;
6use serde::Serialize;
7
8use allowthem_core::{AllowThem, has_scope};
9
10use crate::oauth_bearer::{OAuthBearerError, OAuthBearerToken};
11
12#[derive(Debug, Serialize)]
13pub struct UserInfoResponse {
14 pub sub: String,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 pub preferred_username: Option<String>,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub email: Option<String>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub email_verified: Option<bool>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub custom_data: Option<serde_json::Value>,
23}
24
25async fn userinfo(
26 OAuthBearerToken(claims): OAuthBearerToken,
27 Extension(ath): Extension<AllowThem>,
28) -> Response {
29 let user = match ath.db().get_user(claims.sub).await {
31 Ok(u) => u,
32 Err(_) => {
33 return OAuthBearerError::InvalidToken("user not found".into()).into_response();
34 }
35 };
36
37 if !user.is_active {
39 return OAuthBearerError::InvalidToken("user not found".into()).into_response();
40 }
41
42 let scope = &claims.scope;
44 let mut response = UserInfoResponse {
45 sub: user.id.to_string(),
46 preferred_username: None,
47 email: None,
48 email_verified: None,
49 custom_data: None,
50 };
51
52 if has_scope(scope, "profile") {
53 response.preferred_username = user.username.as_ref().map(|u| u.as_str().to_owned());
54 response.custom_data = user.custom_data.clone();
55 }
56
57 if has_scope(scope, "email") {
58 response.email = Some(user.email.as_str().to_owned());
59 response.email_verified = Some(user.email_verified);
60 }
61
62 (StatusCode::OK, axum::Json(response)).into_response()
63}
64
65pub fn userinfo_route() -> Router<()> {
66 Router::new().route("/oauth/userinfo", get(userinfo).post(userinfo))
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72 use allowthem_core::decrypt_private_key;
73 use allowthem_core::{AllowThem, AllowThemBuilder, Email, UserId};
74 use axum::http::{Request, StatusCode, header::AUTHORIZATION};
75 use jsonwebtoken::{Algorithm, EncodingKey, Header};
76 use serde::Serialize;
77 use tower::ServiceExt;
78
79 const ENC_KEY: [u8; 32] = [0x42; 32];
80 const ISSUER: &str = "https://auth.example.com";
81
82 #[derive(Serialize)]
83 struct TestClaims {
84 sub: String,
85 scope: String,
86 iss: String,
87 aud: String,
88 exp: i64,
89 iat: i64,
90 }
91
92 async fn sign_jwt(ath: &AllowThem, sub: &UserId, scope: &str, exp_offset: i64) -> String {
93 let key = ath.db().create_signing_key(&ENC_KEY).await.unwrap();
94 ath.db().activate_signing_key(key.id).await.unwrap();
95
96 let pem = decrypt_private_key(&key, &ENC_KEY).unwrap();
97 let encoding_key = EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap();
98
99 let now = chrono::Utc::now().timestamp();
100 let claims = TestClaims {
101 sub: sub.to_string(),
102 scope: scope.to_string(),
103 iss: ISSUER.to_string(),
104 aud: "ath_test_client".to_string(),
105 exp: now + exp_offset,
106 iat: now,
107 };
108
109 let mut header = Header::new(Algorithm::RS256);
110 header.kid = Some(key.id.to_string());
111
112 jsonwebtoken::encode(&header, &claims, &encoding_key).unwrap()
113 }
114
115 async fn test_setup() -> (AllowThem, axum::Router, UserId) {
116 let ath = AllowThemBuilder::new("sqlite::memory:")
117 .cookie_secure(false)
118 .signing_key(ENC_KEY)
119 .base_url(ISSUER)
120 .build()
121 .await
122 .unwrap();
123
124 let email = Email::new("test@example.com".into()).unwrap();
125 let user = ath
126 .db()
127 .create_user(email, "password123", None, None)
128 .await
129 .unwrap();
130
131 let app = userinfo_route().layer(axum::middleware::from_fn_with_state(
132 ath.clone(),
133 crate::cors::inject_ath_into_extensions,
134 ));
135
136 (ath, app, user.id)
137 }
138
139 fn bearer_request(jwt: &str) -> Request<axum::body::Body> {
140 Request::builder()
141 .uri("/oauth/userinfo")
142 .header(AUTHORIZATION, format!("Bearer {jwt}"))
143 .body(axum::body::Body::empty())
144 .unwrap()
145 }
146
147 async fn read_json(resp: axum::http::Response<axum::body::Body>) -> serde_json::Value {
148 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
149 .await
150 .unwrap();
151 serde_json::from_slice(&bytes).unwrap()
152 }
153
154 #[tokio::test]
155 async fn userinfo_returns_all_claims_for_full_scope() {
156 let (ath, app, user_id) = test_setup().await;
157 let jwt = sign_jwt(&ath, &user_id, "openid profile email", 300).await;
158
159 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
160 assert_eq!(resp.status(), StatusCode::OK);
161
162 let body = read_json(resp).await;
163 assert!(body.get("sub").is_some());
164 assert!(body.get("email").is_some());
165 assert!(body.get("email_verified").is_some());
166 }
167
168 #[tokio::test]
169 async fn userinfo_openid_only_returns_sub() {
170 let (ath, app, user_id) = test_setup().await;
171 let jwt = sign_jwt(&ath, &user_id, "openid", 300).await;
172
173 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
174 assert_eq!(resp.status(), StatusCode::OK);
175
176 let body = read_json(resp).await;
177 assert!(body.get("sub").is_some());
178 assert!(body.get("preferred_username").is_none());
179 assert!(body.get("email").is_none());
180 assert!(body.get("email_verified").is_none());
181 }
182
183 #[tokio::test]
184 async fn userinfo_profile_without_username_omits_field() {
185 let ath = AllowThemBuilder::new("sqlite::memory:")
186 .cookie_secure(false)
187 .signing_key(ENC_KEY)
188 .base_url(ISSUER)
189 .build()
190 .await
191 .unwrap();
192
193 let email = Email::new("nousername@example.com".into()).unwrap();
195 let user = ath
196 .db()
197 .create_user(email, "password123", None, None)
198 .await
199 .unwrap();
200
201 let jwt = sign_jwt(&ath, &user.id, "openid profile", 300).await;
202 let app = userinfo_route().layer(axum::middleware::from_fn_with_state(
203 ath,
204 crate::cors::inject_ath_into_extensions,
205 ));
206
207 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
208 assert_eq!(resp.status(), StatusCode::OK);
209
210 let body = read_json(resp).await;
211 assert!(body.get("sub").is_some());
212 assert!(body.get("preferred_username").is_none());
213 }
214
215 #[tokio::test]
216 async fn userinfo_inactive_user_returns_401() {
217 let (ath, app, user_id) = test_setup().await;
218 let jwt = sign_jwt(&ath, &user_id, "openid profile email", 300).await;
219
220 ath.db().update_user_active(user_id, false).await.unwrap();
222
223 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
224 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
225 assert!(resp.headers().contains_key("WWW-Authenticate"));
226 }
227
228 #[tokio::test]
229 async fn userinfo_post_works_same_as_get() {
230 let (ath, app, user_id) = test_setup().await;
231 let jwt = sign_jwt(&ath, &user_id, "openid profile email", 300).await;
232
233 let req = Request::builder()
234 .method("POST")
235 .uri("/oauth/userinfo")
236 .header(AUTHORIZATION, format!("Bearer {jwt}"))
237 .body(axum::body::Body::empty())
238 .unwrap();
239
240 let resp = app.oneshot(req).await.unwrap();
241 assert_eq!(resp.status(), StatusCode::OK);
242 let body = read_json(resp).await;
243 assert!(body.get("sub").is_some());
244 }
245
246 #[tokio::test]
247 async fn userinfo_expired_token_returns_401() {
248 let (ath, app, user_id) = test_setup().await;
249 let jwt = sign_jwt(&ath, &user_id, "openid", -60).await;
250
251 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
252 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
253 let www_auth = resp
254 .headers()
255 .get("WWW-Authenticate")
256 .unwrap()
257 .to_str()
258 .unwrap();
259 assert!(www_auth.contains("token expired"));
260 }
261
262 #[tokio::test]
263 async fn userinfo_includes_custom_data_with_profile_scope() {
264 let ath = AllowThemBuilder::new("sqlite::memory:")
265 .cookie_secure(false)
266 .signing_key(ENC_KEY)
267 .base_url(ISSUER)
268 .build()
269 .await
270 .unwrap();
271
272 let email = Email::new("custom@example.com".into()).unwrap();
273 let custom = serde_json::json!({"role": "admin", "plan": "pro"});
274 let user = ath
275 .db()
276 .create_user(email, "password123", None, Some(&custom))
277 .await
278 .unwrap();
279
280 let jwt = sign_jwt(&ath, &user.id, "openid profile", 300).await;
281 let app = userinfo_route().layer(axum::middleware::from_fn_with_state(
282 ath,
283 crate::cors::inject_ath_into_extensions,
284 ));
285
286 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
287 assert_eq!(resp.status(), StatusCode::OK);
288
289 let body = read_json(resp).await;
290 assert!(body.get("sub").is_some());
291 assert_eq!(body["custom_data"]["role"], "admin");
292 assert_eq!(body["custom_data"]["plan"], "pro");
293 }
294
295 #[tokio::test]
296 async fn userinfo_omits_custom_data_without_profile_scope() {
297 let ath = AllowThemBuilder::new("sqlite::memory:")
298 .cookie_secure(false)
299 .signing_key(ENC_KEY)
300 .base_url(ISSUER)
301 .build()
302 .await
303 .unwrap();
304
305 let email = Email::new("custom2@example.com".into()).unwrap();
306 let custom = serde_json::json!({"role": "admin"});
307 let user = ath
308 .db()
309 .create_user(email, "password123", None, Some(&custom))
310 .await
311 .unwrap();
312
313 let jwt = sign_jwt(&ath, &user.id, "openid email", 300).await;
314 let app = userinfo_route().layer(axum::middleware::from_fn_with_state(
315 ath,
316 crate::cors::inject_ath_into_extensions,
317 ));
318
319 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
320 assert_eq!(resp.status(), StatusCode::OK);
321
322 let body = read_json(resp).await;
323 assert!(body.get("sub").is_some());
324 assert!(body.get("custom_data").is_none());
325 }
326}