Skip to main content

siderite_core/security/
oauth2.rs

1//! The OAuth2 password flow: bearer-token extraction and the token form.
2//!
3//! Password-flow types are legacy compatibility. New OAuth applications use
4//! authorization code with PKCE; extraction does not validate access tokens.
5
6use 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
18/// Describes one OAuth2 password-flow scheme; implement on a marker type.
19pub trait OAuth2Spec: Send + Sync + 'static {
20    /// URL of the token endpoint (usually the route taking
21    /// [`OAuth2PasswordRequestForm`]).
22    const TOKEN_URL: &'static str;
23    /// `(scope, description)` pairs the API understands.
24    const SCOPES: &'static [(&'static str, &'static str)];
25    /// Name under `components.securitySchemes`.
26    const SCHEME: &'static str;
27}
28
29/// Bearer token for an OAuth2 password flow described by `S`.
30///
31/// Reads `Authorization: Bearer <token>`; missing or malformed headers yield
32/// `401` with `WWW-Authenticate: Bearer`. The token is not validated.
33pub struct OAuth2PasswordBearer<S: OAuth2Spec> {
34    /// The raw token, without the `Bearer` prefix.
35    pub token: String,
36    spec: PhantomData<fn() -> S>,
37}
38
39impl<S: OAuth2Spec> OAuth2PasswordBearer<S> {
40    /// Wrap a token value.
41    #[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/// The token-endpoint form of the OAuth2 password flow
89/// (`application/x-www-form-urlencoded`, RFC 6749 section 4.3.2).
90///
91/// `username` and `password` are required (`422` when absent). `scope` is a
92/// space-separated list. `grant_type` is accepted but not checked. A wrong
93/// `Content-Type` yields `415`.
94#[derive(Clone, PartialEq, Eq)]
95pub struct OAuth2PasswordRequestForm {
96    /// The user name.
97    pub username: String,
98    /// The password.
99    pub password: String,
100    /// Requested scopes.
101    pub scopes: Vec<String>,
102    /// Optional client identifier.
103    pub client_id: Option<String>,
104    /// Optional client secret.
105    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    /// Build the form from decoded fields.
125    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            // Later duplicates win; the form has no repeated fields.
156            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}