Skip to main content

headless_lms_server/domain/oauth/
authorize_query.rs

1use super::helpers::{oauth_error, oauth_invalid_request};
2use super::oauth_validate::OAuthValidate;
3use super::resource_indicator::ResourceIndicator;
4use crate::prelude::*;
5use domain::error::OAuthErrorCode;
6use std::collections::HashMap;
7
8#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)]
9pub struct AuthorizeQuery {
10    pub response_type: Option<String>,
11    pub client_id: Option<String>,
12    pub redirect_uri: Option<String>,
13    pub scope: Option<String>,
14    pub state: Option<String>,
15    pub nonce: Option<String>,
16    pub code_challenge: Option<String>,
17    pub code_challenge_method: Option<String>,
18
19    pub prompt: Option<String>,
20    pub resource: Option<ResourceIndicator>,
21    // Request is not supported, but we capture it so we can return "not supported" error
22    pub request: Option<String>,
23
24    // OAuth2.0 spec requires that auth does not fail when there are unknown parameters present,
25    // see RFC 6749 3.1
26    #[serde(flatten)]
27    pub _extra: HashMap<String, String>,
28}
29
30#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)]
31pub struct AuthorizeParams {
32    pub response_type: String,
33    pub client_id: String,
34    pub redirect_uri: String,
35    pub scope: String,
36    pub state: Option<String>,
37    pub nonce: Option<String>,
38    pub code_challenge: Option<String>,
39    pub code_challenge_method: Option<String>,
40    pub prompt: Option<String>,
41    pub request: Option<String>,
42    pub resource: Option<ResourceIndicator>,
43}
44
45// We need to make sure we don't return errors directly, instead we need to return
46// error as success request with error parameters to comply with OAuth.
47impl OAuthValidate for AuthorizeQuery {
48    type Output = AuthorizeParams;
49
50    fn validate(&self) -> Result<Self::Output, ControllerError> {
51        let rt_opt = self.response_type.as_deref();
52        let rt = rt_opt.unwrap_or_default();
53
54        let client_id = self.client_id.as_deref().unwrap_or_default();
55        let redirect_uri = self.redirect_uri.as_deref().unwrap_or_default();
56        let scope = self.scope.as_deref().unwrap_or_default();
57
58        let state = self.state.as_deref();
59
60        // Required params check
61        if client_id.is_empty() || redirect_uri.is_empty() || scope.is_empty() {
62            return Err(oauth_invalid_request(
63                "client_id, redirect_uri, and scope are required",
64                None,
65                state,
66            ));
67        }
68
69        // response_type presence check
70        if rt.is_empty() {
71            return Err(oauth_invalid_request(
72                "response_type is required",
73                None,
74                state,
75            ));
76        }
77
78        // Only "code" is supported
79        if rt != "code" {
80            return Err(oauth_error(
81                OAuthErrorCode::UnsupportedResponseType,
82                "unsupported response_type",
83                None,
84                state,
85            ));
86        }
87
88        Ok(AuthorizeParams {
89            response_type: rt.to_string(),
90            client_id: client_id.to_string(),
91            redirect_uri: redirect_uri.to_string(),
92            scope: scope.to_string(),
93            state: self.state.clone(),
94            nonce: self.nonce.clone(),
95            code_challenge: self.code_challenge.clone(),
96            code_challenge_method: self.code_challenge_method.clone(),
97            prompt: self.prompt.clone(),
98            request: self.request.clone(),
99            resource: self.resource.clone(),
100        })
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use domain::error::{ControllerError, ControllerErrorType, OAuthErrorCode};
108    use serde_json::{Value, json};
109
110    fn assert_oauth_error(
111        result: Result<AuthorizeParams, ControllerError>,
112        expected_error: OAuthErrorCode,
113        expected_description: &str,
114    ) {
115        match result {
116            Err(err) => match err.error_type() {
117                ControllerErrorType::OAuthError(data) => {
118                    assert_eq!(data.error, expected_error.as_str());
119                    assert_eq!(data.error_description, expected_description);
120                }
121                other => panic!("Expected OAuthError, got {:?}", other),
122            },
123            Ok(_) => panic!("Expected Err, got Ok(_)"),
124        }
125    }
126
127    #[test]
128    fn authorize_missing_fields() {
129        let q = AuthorizeQuery {
130            response_type: Some("code".into()),
131            client_id: None,
132            redirect_uri: None,
133            scope: None,
134            state: Some("xyz".into()),
135            nonce: None,
136            code_challenge: None,
137            code_challenge_method: None,
138            prompt: None,
139            request: None,
140            resource: None,
141            _extra: Default::default(),
142        };
143        let res = q.validate();
144        assert_oauth_error(
145            res,
146            OAuthErrorCode::InvalidRequest,
147            "client_id, redirect_uri, and scope are required",
148        );
149    }
150
151    #[test]
152    fn authorize_unsupported_response_type() {
153        let q = AuthorizeQuery {
154            response_type: Some("token".into()),
155            client_id: Some("cid".into()),
156            redirect_uri: Some("http://localhost".into()),
157            scope: Some("openid".into()),
158            state: None,
159            nonce: None,
160            code_challenge: None,
161            code_challenge_method: None,
162            prompt: None,
163            request: None,
164            resource: None,
165            _extra: Default::default(),
166        };
167        let res = q.validate();
168        assert_oauth_error(
169            res,
170            OAuthErrorCode::UnsupportedResponseType,
171            "unsupported response_type",
172        );
173    }
174
175    #[test]
176    fn authorize_valid_code_flow_openid_without_nonce_is_ok() {
177        // For pure code flow, nonce is not required by OIDC core.
178        let q = AuthorizeQuery {
179            response_type: Some("code".into()),
180            client_id: Some("cid".into()),
181            redirect_uri: Some("http://localhost".into()),
182            scope: Some("openid profile".into()),
183            state: Some("s".into()),
184            nonce: None,
185            code_challenge: None,
186            code_challenge_method: None,
187            prompt: None,
188            request: None,
189            resource: None,
190            _extra: Default::default(),
191        };
192        assert!(q.validate().is_ok());
193    }
194
195    #[test]
196    fn authorize_unknown_params_are_captured_in_extra() {
197        let v: Value = json!({
198            "response_type": "code",
199            "client_id": "cid",
200            "redirect_uri": "http://localhost",
201            "scope": "openid",
202            "state": "s",
203            "foo": "bar",
204            "x": "y"
205        });
206        let q: AuthorizeQuery = serde_json::from_value(v).unwrap();
207        assert_eq!(q._extra.get("foo").map(String::as_str), Some("bar"));
208        assert_eq!(q._extra.get("x").map(String::as_str), Some("y"));
209        assert!(q.validate().is_ok());
210    }
211
212    #[test]
213    fn authorize_missing_response_type_is_invalid_request() {
214        let q = AuthorizeQuery {
215            response_type: None,
216            client_id: Some("cid".into()),
217            redirect_uri: Some("http://localhost".into()),
218            scope: Some("openid".into()),
219            state: None,
220            nonce: None,
221            code_challenge: None,
222            code_challenge_method: None,
223            prompt: None,
224            request: None,
225            resource: None,
226            _extra: Default::default(),
227        };
228        let res = q.validate();
229        assert_oauth_error(
230            res,
231            OAuthErrorCode::InvalidRequest,
232            "response_type is required",
233        );
234    }
235
236    #[test]
237    fn authorize_pkce_fields_passthrough() {
238        let q = AuthorizeQuery {
239            response_type: Some("code".into()),
240            client_id: Some("cid".into()),
241            redirect_uri: Some("http://localhost".into()),
242            scope: Some("openid profile".into()),
243            state: Some("s".into()),
244            nonce: Some("n".into()),
245            code_challenge: Some("abcDEF123-_".into()),
246            code_challenge_method: Some("S256".into()),
247            prompt: None,
248            request: None,
249            resource: None,
250            _extra: Default::default(),
251        };
252        let p = q.validate().expect("validate should pass");
253        assert_eq!(p.code_challenge.as_deref(), Some("abcDEF123-_"));
254        assert_eq!(p.code_challenge_method.as_deref(), Some("S256"));
255    }
256}