1use axum::Router;
2use axum::extract::State;
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 State(ath): State<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<AllowThem> {
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().with_state(ath.clone());
132
133 (ath, app, user.id)
134 }
135
136 fn bearer_request(jwt: &str) -> Request<axum::body::Body> {
137 Request::builder()
138 .uri("/oauth/userinfo")
139 .header(AUTHORIZATION, format!("Bearer {jwt}"))
140 .body(axum::body::Body::empty())
141 .unwrap()
142 }
143
144 async fn read_json(resp: axum::http::Response<axum::body::Body>) -> serde_json::Value {
145 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
146 .await
147 .unwrap();
148 serde_json::from_slice(&bytes).unwrap()
149 }
150
151 #[tokio::test]
152 async fn userinfo_returns_all_claims_for_full_scope() {
153 let (ath, app, user_id) = test_setup().await;
154 let jwt = sign_jwt(&ath, &user_id, "openid profile email", 300).await;
155
156 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
157 assert_eq!(resp.status(), StatusCode::OK);
158
159 let body = read_json(resp).await;
160 assert!(body.get("sub").is_some());
161 assert!(body.get("email").is_some());
162 assert!(body.get("email_verified").is_some());
163 }
164
165 #[tokio::test]
166 async fn userinfo_openid_only_returns_sub() {
167 let (ath, app, user_id) = test_setup().await;
168 let jwt = sign_jwt(&ath, &user_id, "openid", 300).await;
169
170 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
171 assert_eq!(resp.status(), StatusCode::OK);
172
173 let body = read_json(resp).await;
174 assert!(body.get("sub").is_some());
175 assert!(body.get("preferred_username").is_none());
176 assert!(body.get("email").is_none());
177 assert!(body.get("email_verified").is_none());
178 }
179
180 #[tokio::test]
181 async fn userinfo_profile_without_username_omits_field() {
182 let ath = AllowThemBuilder::new("sqlite::memory:")
183 .cookie_secure(false)
184 .signing_key(ENC_KEY)
185 .base_url(ISSUER)
186 .build()
187 .await
188 .unwrap();
189
190 let email = Email::new("nousername@example.com".into()).unwrap();
192 let user = ath
193 .db()
194 .create_user(email, "password123", None, None)
195 .await
196 .unwrap();
197
198 let jwt = sign_jwt(&ath, &user.id, "openid profile", 300).await;
199 let app = userinfo_route().with_state(ath);
200
201 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
202 assert_eq!(resp.status(), StatusCode::OK);
203
204 let body = read_json(resp).await;
205 assert!(body.get("sub").is_some());
206 assert!(body.get("preferred_username").is_none());
207 }
208
209 #[tokio::test]
210 async fn userinfo_inactive_user_returns_401() {
211 let (ath, app, user_id) = test_setup().await;
212 let jwt = sign_jwt(&ath, &user_id, "openid profile email", 300).await;
213
214 ath.db().update_user_active(user_id, false).await.unwrap();
216
217 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
218 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
219 assert!(resp.headers().contains_key("WWW-Authenticate"));
220 }
221
222 #[tokio::test]
223 async fn userinfo_post_works_same_as_get() {
224 let (ath, app, user_id) = test_setup().await;
225 let jwt = sign_jwt(&ath, &user_id, "openid profile email", 300).await;
226
227 let req = Request::builder()
228 .method("POST")
229 .uri("/oauth/userinfo")
230 .header(AUTHORIZATION, format!("Bearer {jwt}"))
231 .body(axum::body::Body::empty())
232 .unwrap();
233
234 let resp = app.oneshot(req).await.unwrap();
235 assert_eq!(resp.status(), StatusCode::OK);
236 let body = read_json(resp).await;
237 assert!(body.get("sub").is_some());
238 }
239
240 #[tokio::test]
241 async fn userinfo_expired_token_returns_401() {
242 let (ath, app, user_id) = test_setup().await;
243 let jwt = sign_jwt(&ath, &user_id, "openid", -60).await;
244
245 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
246 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
247 let www_auth = resp
248 .headers()
249 .get("WWW-Authenticate")
250 .unwrap()
251 .to_str()
252 .unwrap();
253 assert!(www_auth.contains("token expired"));
254 }
255
256 #[tokio::test]
257 async fn userinfo_includes_custom_data_with_profile_scope() {
258 let ath = AllowThemBuilder::new("sqlite::memory:")
259 .cookie_secure(false)
260 .signing_key(ENC_KEY)
261 .base_url(ISSUER)
262 .build()
263 .await
264 .unwrap();
265
266 let email = Email::new("custom@example.com".into()).unwrap();
267 let custom = serde_json::json!({"role": "admin", "plan": "pro"});
268 let user = ath
269 .db()
270 .create_user(email, "password123", None, Some(&custom))
271 .await
272 .unwrap();
273
274 let jwt = sign_jwt(&ath, &user.id, "openid profile", 300).await;
275 let app = userinfo_route().with_state(ath);
276
277 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
278 assert_eq!(resp.status(), StatusCode::OK);
279
280 let body = read_json(resp).await;
281 assert!(body.get("sub").is_some());
282 assert_eq!(body["custom_data"]["role"], "admin");
283 assert_eq!(body["custom_data"]["plan"], "pro");
284 }
285
286 #[tokio::test]
287 async fn userinfo_omits_custom_data_without_profile_scope() {
288 let ath = AllowThemBuilder::new("sqlite::memory:")
289 .cookie_secure(false)
290 .signing_key(ENC_KEY)
291 .base_url(ISSUER)
292 .build()
293 .await
294 .unwrap();
295
296 let email = Email::new("custom2@example.com".into()).unwrap();
297 let custom = serde_json::json!({"role": "admin"});
298 let user = ath
299 .db()
300 .create_user(email, "password123", None, Some(&custom))
301 .await
302 .unwrap();
303
304 let jwt = sign_jwt(&ath, &user.id, "openid email", 300).await;
305 let app = userinfo_route().with_state(ath);
306
307 let resp = app.oneshot(bearer_request(&jwt)).await.unwrap();
308 assert_eq!(resp.status(), StatusCode::OK);
309
310 let body = read_json(resp).await;
311 assert!(body.get("sub").is_some());
312 assert!(body.get("custom_data").is_none());
313 }
314}