Skip to main content

allowthem_server/
userinfo_route.rs

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    // 1. Fetch user by claims.sub
30    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    // 2. Check user is active
38    if !user.is_active {
39        return OAuthBearerError::InvalidToken("user not found".into()).into_response();
40    }
41
42    // 3. Build response based on granted scopes
43    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        // Create user with no username
194        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        // Deactivate user
221        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}