1use super::{bearer_token, document_scheme};
7use crate::body::Body;
8use crate::error::ApiError;
9use crate::extract::{FromRequest, FromRequestParts, Request, invalid};
10use http::StatusCode;
11use http::request::Parts;
12use serde_json::{Map, Value, json};
13use siderite_openapi::{Operation, SchemaObject, SchemaRegistry};
14use std::collections::HashMap;
15use std::fmt;
16use std::marker::PhantomData;
17
18pub trait OAuth2Spec: Send + Sync + 'static {
20 const TOKEN_URL: &'static str;
23 const SCOPES: &'static [(&'static str, &'static str)];
25 const SCHEME: &'static str;
27}
28
29pub struct OAuth2PasswordBearer<S: OAuth2Spec> {
34 pub token: String,
36 spec: PhantomData<fn() -> S>,
37}
38
39impl<S: OAuth2Spec> OAuth2PasswordBearer<S> {
40 #[must_use]
42 pub fn new(token: impl Into<String>) -> Self {
43 Self {
44 token: token.into(),
45 spec: PhantomData,
46 }
47 }
48}
49
50impl<S: OAuth2Spec> fmt::Debug for OAuth2PasswordBearer<S> {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 f.debug_struct("OAuth2PasswordBearer")
53 .field("scheme", &S::SCHEME)
54 .field("token", &"[REDACTED]")
55 .finish()
56 }
57}
58
59impl<S: OAuth2Spec> Clone for OAuth2PasswordBearer<S> {
60 fn clone(&self) -> Self {
61 Self::new(self.token.clone())
62 }
63}
64
65impl<S: OAuth2Spec> FromRequestParts for OAuth2PasswordBearer<S> {
66 async fn from_request_parts(parts: &mut Parts) -> Result<Self, ApiError> {
67 bearer_token(parts).map(Self::new)
68 }
69
70 fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
71 let scopes: Map<String, Value> = S::SCOPES
72 .iter()
73 .map(|(scope, description)| ((*scope).to_owned(), json!(description)))
74 .collect();
75 document_scheme(
76 op,
77 registry,
78 S::SCHEME,
79 json!({
80 "type": "oauth2",
81 "flows": {"password": {"tokenUrl": S::TOKEN_URL, "scopes": scopes}},
82 }),
83 &[],
84 );
85 }
86}
87
88#[derive(Clone, PartialEq, Eq)]
95pub struct OAuth2PasswordRequestForm {
96 pub username: String,
98 pub password: String,
100 pub scopes: Vec<String>,
102 pub client_id: Option<String>,
104 pub client_secret: Option<String>,
106}
107
108impl fmt::Debug for OAuth2PasswordRequestForm {
109 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110 f.debug_struct("OAuth2PasswordRequestForm")
111 .field("username", &self.username)
112 .field("password", &"[REDACTED]")
113 .field("scopes", &self.scopes)
114 .field("client_id", &self.client_id)
115 .field(
116 "client_secret",
117 &self.client_secret.as_ref().map(|_| "[REDACTED]"),
118 )
119 .finish()
120 }
121}
122
123impl OAuth2PasswordRequestForm {
124 fn from_fields(mut fields: HashMap<String, String>) -> Result<Self, ApiError> {
126 let mut required = |name: &str| {
127 fields
128 .remove(name)
129 .filter(|value| !value.is_empty())
130 .ok_or_else(|| invalid("body", "missing", format!("missing form field `{name}`")))
131 };
132 let username = required("username")?;
133 let password = required("password")?;
134 let scopes = fields
135 .remove("scope")
136 .map(|scope| scope.split_whitespace().map(str::to_owned).collect())
137 .unwrap_or_default();
138 Ok(Self {
139 username,
140 password,
141 scopes,
142 client_id: fields.remove("client_id").filter(|v| !v.is_empty()),
143 client_secret: fields.remove("client_secret").filter(|v| !v.is_empty()),
144 })
145 }
146}
147
148impl FromRequest for OAuth2PasswordRequestForm {
149 async fn from_request(req: Request) -> Result<Self, ApiError> {
150 use axum::extract::FromRequest as _;
151 use axum::extract::rejection::FormRejection;
152 match axum::Form::<Vec<(String, String)>>::from_request(req.map(Body::into_inner), &())
153 .await
154 {
155 Ok(axum::Form(pairs)) => Self::from_fields(pairs.into_iter().collect()),
157 Err(rejection) => {
158 let message = rejection.body_text();
159 Err(match rejection {
160 FormRejection::InvalidFormContentType(_) => {
161 ApiError::new(StatusCode::UNSUPPORTED_MEDIA_TYPE, message)
162 }
163 FormRejection::FailedToDeserializeForm(_)
164 | FormRejection::FailedToDeserializeFormBody(_) => {
165 invalid("body", "form_invalid", message)
166 }
167 other => ApiError::new(other.status(), message),
168 })
169 }
170 }
171 }
172
173 fn describe(op: &mut Operation, _registry: &mut SchemaRegistry) {
174 let string = || json!({"type": "string"});
175 let schema = SchemaObject::of_type("object")
176 .with(
177 "properties",
178 json!({
179 "grant_type": {"type": "string", "pattern": "^password$"},
180 "username": string(),
181 "password": string(),
182 "scope": string(),
183 "client_id": string(),
184 "client_secret": string(),
185 }),
186 )
187 .with("required", json!(["username", "password"]));
188 op.set_request_body("application/x-www-form-urlencoded", schema, true);
189 }
190}
191
192#[cfg(test)]
193#[allow(clippy::unwrap_used)]
194mod tests {
195 use super::*;
196
197 fn fields(pairs: &[(&str, &str)]) -> HashMap<String, String> {
198 pairs
199 .iter()
200 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
201 .collect()
202 }
203
204 #[test]
205 fn splits_scopes_on_whitespace() {
206 let form = OAuth2PasswordRequestForm::from_fields(fields(&[
207 ("username", "ann"),
208 ("password", "pw"),
209 ("scope", "read write"),
210 ]))
211 .unwrap();
212 assert_eq!(form.scopes, ["read", "write"]);
213 assert_eq!(form.client_id, None);
214 }
215
216 #[test]
217 fn requires_username_and_password() {
218 let err =
219 OAuth2PasswordRequestForm::from_fields(fields(&[("username", "ann")])).unwrap_err();
220 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
221 }
222
223 #[test]
224 fn debug_output_redacts_secrets() {
225 let form = OAuth2PasswordRequestForm::from_fields(fields(&[
226 ("username", "ann"),
227 ("password", "hunter2"),
228 ("client_secret", "shh"),
229 ]))
230 .unwrap();
231 let text = format!("{form:?}");
232 assert!(!text.contains("hunter2") && !text.contains("shh"));
233 }
234}