Skip to main content

headless_lms_server/domain/oauth/
token_query.rs

1use super::oauth_validate::OAuthValidate;
2use crate::prelude::*;
3use domain::error::{OAuthErrorCode, OAuthErrorData};
4use models::library::oauth::GrantTypeName;
5use secrecy::{ExposeSecret, SecretString};
6use serde::Deserialize;
7use std::collections::HashMap;
8
9#[derive(Debug, Deserialize, Clone, Default)]
10pub struct TokenQuery {
11    pub client_id: Option<String>,
12    pub client_secret: Option<SecretString>, // optional: public clients won't send this
13    #[serde(flatten)]
14    pub grant: Option<TokenGrant>,
15    // OAuth 2.0 requires unknown params be ignored at /token (RFC 6749 §3.2)
16    #[serde(flatten)]
17    pub _extra: HashMap<String, String>,
18}
19
20#[derive(Debug, Clone)]
21pub struct TokenParams {
22    pub client_id: String,
23    pub client_secret: Option<SecretString>, // carry through; validation for presence is done per-client later
24    pub grant: TokenGrant,
25}
26
27impl OAuthValidate for TokenQuery {
28    type Output = TokenParams;
29
30    fn validate(&self) -> Result<Self::Output, ControllerError> {
31        let client_id = self.client_id.as_deref().unwrap_or_default();
32
33        if client_id.is_empty() {
34            return Err(ControllerError::new(
35                ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
36                    error: OAuthErrorCode::InvalidClient.as_str().into(),
37                    error_description: "client_id is required".into(),
38                    redirect_uri: None,
39                    state: None,
40                    nonce: None,
41                })),
42                "Missing client_id",
43                None::<anyhow::Error>,
44            ));
45        }
46
47        // Grant-specific required params
48        let grant = match self.grant.clone() {
49            Some(grant @ TokenGrant::AuthorizationCode { .. }) => {
50                if let TokenGrant::AuthorizationCode {
51                    code, redirect_uri, ..
52                } = &grant
53                {
54                    if code.expose_secret().is_empty() {
55                        return Err(ControllerError::new(
56                            ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
57                                error: OAuthErrorCode::InvalidRequest.as_str().into(),
58                                error_description: "code is required for authorization_code grant"
59                                    .into(),
60                                redirect_uri: None,
61                                state: None,
62                                nonce: None,
63                            })),
64                            "Missing authorization code",
65                            None::<anyhow::Error>,
66                        ));
67                    }
68                    // If redirect_uri is provided, it must not be empty
69                    if matches!(redirect_uri.as_deref(), Some("")) {
70                        return Err(ControllerError::new(
71                            ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
72                                error: OAuthErrorCode::InvalidRequest.as_str().into(),
73                                error_description: "redirect_uri must not be empty when provided"
74                                    .into(),
75                                redirect_uri: None,
76                                state: None,
77                                nonce: None,
78                            })),
79                            "Empty redirect_uri",
80                            None::<anyhow::Error>,
81                        ));
82                    }
83                }
84                // PKCE code_verifier is verified at the token handler (if the code had a challenge)
85                grant
86            }
87            Some(grant @ TokenGrant::RefreshToken { .. }) => {
88                if let TokenGrant::RefreshToken { refresh_token, .. } = &grant
89                    && refresh_token.expose_secret().is_empty()
90                {
91                    return Err(ControllerError::new(
92                        ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
93                            error: OAuthErrorCode::InvalidRequest.as_str().into(),
94                            error_description: "refresh_token is required".into(),
95                            redirect_uri: None,
96                            state: None,
97                            nonce: None,
98                        })),
99                        "Missing refresh token",
100                        None::<anyhow::Error>,
101                    ));
102                }
103                grant
104            }
105            Some(grant @ TokenGrant::DeviceCode { .. }) => {
106                if let TokenGrant::DeviceCode { device_code } = &grant
107                    && device_code.expose_secret().is_empty()
108                {
109                    return Err(ControllerError::new(
110                        ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
111                            error: OAuthErrorCode::InvalidRequest.as_str().into(),
112                            error_description: "device_code is required".into(),
113                            redirect_uri: None,
114                            state: None,
115                            nonce: None,
116                        })),
117                        "Missing device code",
118                        None::<anyhow::Error>,
119                    ));
120                }
121                grant
122            }
123            Some(TokenGrant::Unknown) => {
124                return Err(ControllerError::new(
125                    ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
126                        error: OAuthErrorCode::UnsupportedGrantType.as_str().into(),
127                        error_description: "unsupported grant type".into(),
128                        redirect_uri: None,
129                        state: None,
130                        nonce: None,
131                    })),
132                    "Unsupported grant type",
133                    None::<anyhow::Error>,
134                ));
135            }
136            None => {
137                return Err(ControllerError::new(
138                    ControllerErrorType::OAuthError(Box::new(OAuthErrorData {
139                        error: OAuthErrorCode::InvalidRequest.as_str().into(),
140                        error_description: "grant_type is required".into(),
141                        redirect_uri: None,
142                        state: None,
143                        nonce: None,
144                    })),
145                    "Missing grant type",
146                    None::<anyhow::Error>,
147                ));
148            }
149        };
150
151        Ok(TokenParams {
152            client_id: client_id.to_string(),
153            client_secret: self.client_secret.clone(), // may be None for public clients
154            grant,
155        })
156    }
157}
158
159#[derive(Debug, Deserialize, Clone)]
160#[serde(tag = "grant_type", rename_all = "snake_case")]
161pub enum TokenGrant {
162    AuthorizationCode {
163        code: SecretString,
164        /// Optional per RFC 6749 §4.1.3 (required if it was present in the authorization request)
165        redirect_uri: Option<String>,
166        /// Optional; enforced later if the code stored a challenge
167        code_verifier: Option<SecretString>,
168    },
169    RefreshToken {
170        refresh_token: SecretString,
171        /// Optional down-scope
172        #[serde(default)]
173        scope: Option<String>,
174    },
175    /// OAuth 2.0 Device Authorization Grant (RFC 8628). The `grant_type` value
176    /// is the exact URN `urn:ietf:params:oauth:grant-type:device_code`.
177    #[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")]
178    DeviceCode { device_code: SecretString },
179    #[serde(other)]
180    Unknown,
181}
182
183impl TokenGrant {
184    pub fn kind(&self) -> GrantTypeName {
185        match self {
186            TokenGrant::AuthorizationCode { .. } => GrantTypeName::AuthorizationCode,
187            TokenGrant::RefreshToken { .. } => GrantTypeName::RefreshToken,
188            TokenGrant::DeviceCode { .. } => GrantTypeName::DeviceCode,
189            TokenGrant::Unknown => {
190                unreachable!(
191                    "Unknown grant type should be caught by validate() before kind() is called"
192                )
193            }
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use domain::error::{ControllerError, ControllerErrorType, OAuthErrorCode};
202    use serde_json::{Value, json};
203
204    fn assert_oauth_error(
205        result: Result<TokenParams, ControllerError>,
206        expected_error: OAuthErrorCode,
207        expected_description: &str,
208    ) {
209        match result {
210            Err(err) => match err.error_type() {
211                ControllerErrorType::OAuthError(data) => {
212                    assert_eq!(data.error, expected_error.as_str());
213                    assert_eq!(data.error_description, expected_description);
214                }
215                other => panic!("Expected OAuthError, got {:?}", other),
216            },
217            Ok(_) => panic!("Expected Err, got Ok(())"),
218        }
219    }
220
221    #[test]
222    fn token_missing_client_id() {
223        let q = TokenQuery {
224            client_id: None,
225            client_secret: None,
226            grant: None,
227            _extra: Default::default(),
228        };
229        let res = q.validate();
230        assert_oauth_error(res, OAuthErrorCode::InvalidClient, "client_id is required");
231    }
232
233    #[test]
234    fn token_public_client_without_secret_is_ok() {
235        let q = TokenQuery {
236            client_id: Some("cid".into()),
237            client_secret: None,
238            grant: Some(TokenGrant::RefreshToken {
239                refresh_token: "rt".into(),
240                scope: None,
241            }),
242            _extra: Default::default(),
243        };
244        assert!(q.validate().is_ok());
245    }
246
247    #[test]
248    fn token_missing_grant_type() {
249        let q = TokenQuery {
250            client_id: Some("cid".into()),
251            client_secret: Some("sec".into()),
252            grant: None,
253            _extra: Default::default(),
254        };
255        let res = q.validate();
256        assert_oauth_error(
257            res,
258            OAuthErrorCode::InvalidRequest,
259            "grant_type is required",
260        );
261    }
262
263    #[test]
264    fn token_auth_code_missing_code() {
265        let q = TokenQuery {
266            client_id: Some("cid".into()),
267            client_secret: Some("sec".into()),
268            grant: Some(TokenGrant::AuthorizationCode {
269                code: "".into(),
270                redirect_uri: Some("http://localhost".into()),
271                code_verifier: None,
272            }),
273            _extra: Default::default(),
274        };
275        let res = q.validate();
276        assert_oauth_error(
277            res,
278            OAuthErrorCode::InvalidRequest,
279            "code is required for authorization_code grant",
280        );
281    }
282
283    #[test]
284    fn token_auth_code_empty_redirect_uri_is_invalid() {
285        let q = TokenQuery {
286            client_id: Some("cid".into()),
287            client_secret: Some("sec".into()),
288            grant: Some(TokenGrant::AuthorizationCode {
289                code: "C".into(),
290                redirect_uri: Some("".into()),
291                code_verifier: None,
292            }),
293            _extra: Default::default(),
294        };
295        let res = q.validate();
296        assert_oauth_error(
297            res,
298            OAuthErrorCode::InvalidRequest,
299            "redirect_uri must not be empty when provided",
300        );
301    }
302
303    #[test]
304    fn token_auth_code_minimal_ok_without_redirect_uri_or_pkce() {
305        // Allowed by validator; actual PKCE/redirect checks happen in handler.
306        let q = TokenQuery {
307            client_id: Some("cid".into()),
308            client_secret: Some("sec".into()),
309            grant: Some(TokenGrant::AuthorizationCode {
310                code: "C".into(),
311                redirect_uri: None,
312                code_verifier: None,
313            }),
314            _extra: Default::default(),
315        };
316        assert!(q.validate().is_ok());
317    }
318
319    #[test]
320    fn token_auth_code_with_pkce_ok() {
321        let q = TokenQuery {
322            client_id: Some("cid".into()),
323            client_secret: Some("sec".into()),
324            grant: Some(TokenGrant::AuthorizationCode {
325                code: "C".into(),
326                redirect_uri: Some("http://localhost".into()),
327                code_verifier: Some("verifier".into()),
328            }),
329            _extra: Default::default(),
330        };
331        assert!(q.validate().is_ok());
332    }
333
334    #[test]
335    fn token_refresh_missing_field() {
336        let q = TokenQuery {
337            client_id: Some("cid".into()),
338            client_secret: Some("sec".into()),
339            grant: Some(TokenGrant::RefreshToken {
340                refresh_token: "".into(),
341                scope: None,
342            }),
343            _extra: Default::default(),
344        };
345        let res = q.validate();
346        assert_oauth_error(
347            res,
348            OAuthErrorCode::InvalidRequest,
349            "refresh_token is required",
350        );
351    }
352
353    #[test]
354    fn token_valid_auth_code() {
355        let q = TokenQuery {
356            client_id: Some("cid".into()),
357            client_secret: Some("sec".into()),
358            grant: Some(TokenGrant::AuthorizationCode {
359                code: "abc".into(),
360                redirect_uri: Some("http://localhost".into()),
361                code_verifier: None,
362            }),
363            _extra: Default::default(),
364        };
365        assert!(q.validate().is_ok());
366    }
367
368    #[test]
369    fn token_device_code_missing_field() {
370        let q = TokenQuery {
371            client_id: Some("cid".into()),
372            client_secret: None,
373            grant: Some(TokenGrant::DeviceCode {
374                device_code: "".into(),
375            }),
376            _extra: Default::default(),
377        };
378        let res = q.validate();
379        assert_oauth_error(
380            res,
381            OAuthErrorCode::InvalidRequest,
382            "device_code is required",
383        );
384    }
385
386    #[test]
387    fn token_valid_device_code() {
388        let q = TokenQuery {
389            client_id: Some("cid".into()),
390            client_secret: None,
391            grant: Some(TokenGrant::DeviceCode {
392                device_code: "dc".into(),
393            }),
394            _extra: Default::default(),
395        };
396        assert!(q.validate().is_ok());
397    }
398
399    #[test]
400    fn token_valid_refresh_token() {
401        let q = TokenQuery {
402            client_id: Some("cid".into()),
403            client_secret: Some("sec".into()),
404            grant: Some(TokenGrant::RefreshToken {
405                refresh_token: "r1".into(),
406                scope: None,
407            }),
408            _extra: Default::default(),
409        };
410        assert!(q.validate().is_ok());
411    }
412
413    #[test]
414    fn token_unknown_params_are_captured_in_extra() {
415        let v: Value = json!({
416            "client_id": "cid",
417            "client_secret": "sec",
418            "grant_type": "refresh_token",
419            "refresh_token": "rt",
420            "extra_param": "zzz"
421        });
422        let q: TokenQuery = serde_json::from_value(v).unwrap();
423        assert_eq!(q._extra.get("extra_param").map(String::as_str), Some("zzz"));
424        assert!(q.validate().is_ok());
425    }
426
427    #[test]
428    fn token_grant_tagging_deserializes_properly() {
429        // authorization_code branch
430        let ac: TokenQuery = serde_json::from_value(json!({
431            "client_id": "cid",
432            "client_secret": "sec",
433            "grant_type": "authorization_code",
434            "code": "C",
435            "redirect_uri": "http://localhost",
436            "code_verifier": "ver"
437        }))
438        .unwrap();
439        match ac.grant {
440            Some(TokenGrant::AuthorizationCode {
441                code,
442                redirect_uri,
443                code_verifier,
444            }) => {
445                assert_eq!(code.expose_secret(), "C");
446                assert_eq!(redirect_uri.as_deref(), Some("http://localhost"));
447                assert_eq!(
448                    code_verifier.as_ref().map(|v| v.expose_secret()),
449                    Some("ver")
450                );
451            }
452            _ => panic!("expected AuthorizationCode"),
453        }
454
455        // refresh_token branch
456        let rt: TokenQuery = serde_json::from_value(json!({
457            "client_id": "cid",
458            "client_secret": "sec",
459            "grant_type": "refresh_token",
460            "refresh_token": "R",
461            "scope": "read write"
462        }))
463        .unwrap();
464        match rt.grant {
465            Some(TokenGrant::RefreshToken {
466                refresh_token,
467                scope,
468            }) => {
469                assert_eq!(refresh_token.expose_secret(), "R");
470                assert_eq!(scope.as_deref(), Some("read write"));
471            }
472            _ => panic!("expected RefreshToken"),
473        }
474
475        // device_code branch (RFC 8628 URN grant_type)
476        let dc: TokenQuery = serde_json::from_value(json!({
477            "client_id": "cid",
478            "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
479            "device_code": "DEV"
480        }))
481        .unwrap();
482        match dc.grant {
483            Some(TokenGrant::DeviceCode { device_code }) => {
484                assert_eq!(device_code.expose_secret(), "DEV");
485            }
486            _ => panic!("expected DeviceCode"),
487        }
488    }
489}