headless_lms_server/domain/oauth/
authorize_query.rs1use 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 pub request: Option<String>,
23
24 #[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
45impl 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 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 if rt.is_empty() {
71 return Err(oauth_invalid_request(
72 "response_type is required",
73 None,
74 state,
75 ));
76 }
77
78 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 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}