Skip to main content

allowthem_server/
userinfo_route.rs

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    // 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<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        // Create user with no username
191        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        // Deactivate user
215        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}