Skip to main content

seamless_auth/
guard.rs

1use std::fmt;
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::Arc;
5use std::task::{Context, Poll};
6
7use axum::extract::{FromRequestParts, Request};
8use axum::http::{HeaderMap, StatusCode, request::Parts};
9use axum::response::{IntoResponse, Response};
10use serde_json::{Value, json};
11use tower_layer::Layer;
12use tower_service::Service;
13
14use crate::adapter::{Adapter, Inner, bearer_token, json_response};
15use crate::cookies::{KIND_ACCESS, raw_cookie, read_cookie};
16use crate::jwt::{Claims, str_claim, unverified_claims};
17use crate::origin::check_origin;
18
19/// The session a request was authenticated with. [`RequireAuth`] puts it in the
20/// request extensions, and it is an extractor for handlers behind the guard.
21#[derive(Clone, PartialEq, Eq)]
22#[non_exhaustive]
23pub struct User {
24    pub id: String,
25    pub roles: Vec<String>,
26    pub email: Option<String>,
27    pub phone: Option<String>,
28    /// The auth API access token, for calling the API on the user's behalf.
29    pub token: String,
30}
31
32// The token is a live credential, so it stays out of logs that print a User.
33impl fmt::Debug for User {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.debug_struct("User")
36            .field("id", &self.id)
37            .field("roles", &self.roles)
38            .field("email", &self.email)
39            .field("phone", &self.phone)
40            .field("token", &"[redacted]")
41            .finish()
42    }
43}
44
45/// A request carried no valid session. Answers 401 `{"error":"unauthenticated"}`.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub struct Unauthenticated;
48
49impl fmt::Display for Unauthenticated {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str("unauthenticated")
52    }
53}
54
55impl std::error::Error for Unauthenticated {}
56
57impl IntoResponse for Unauthenticated {
58    fn into_response(self) -> Response {
59        json_response(
60            StatusCode::UNAUTHORIZED,
61            &json!({ "error": "unauthenticated" }),
62        )
63    }
64}
65
66impl<S: Send + Sync> FromRequestParts<S> for User {
67    type Rejection = Unauthenticated;
68
69    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
70        parts
71            .extensions
72            .get::<User>()
73            .cloned()
74            .ok_or(Unauthenticated)
75    }
76}
77
78impl Adapter {
79    /// Verifies a request's session: the adapter's access cookie, or an auth API
80    /// access token in `Authorization: Bearer` for clients with no cookie jar. The
81    /// cookie wins when present. It reads the `Cookie` header itself, so it works
82    /// on any route. It does not refresh: silent refresh belongs to the auth
83    /// routes, and a bearer client refreshes through `POST /auth/refresh` itself.
84    pub async fn authenticate(&self, headers: &HeaderMap) -> Result<User, Unauthenticated> {
85        self.inner.authenticate(headers).await.map(|(user, _)| user)
86    }
87
88    /// A layer that answers 401 unless the request carries a valid session, and
89    /// puts the [`User`] in the request extensions for the handler behind it.
90    pub fn require_auth(&self) -> RequireAuthLayer {
91        RequireAuthLayer {
92            inner: self.inner.clone(),
93        }
94    }
95}
96
97impl Inner {
98    /// The session, and whether it came from the cookie (which a browser attaches
99    /// to cross-site requests) rather than a bearer token (which it never does).
100    async fn authenticate(&self, headers: &HeaderMap) -> Result<(User, bool), Unauthenticated> {
101        let name = &self.config.access_cookie_name;
102        if raw_cookie(headers, name).is_some_and(|v| !v.is_empty()) {
103            let claims =
104                read_cookie(&self.config, headers, name, KIND_ACCESS).ok_or(Unauthenticated)?;
105            let token = str_claim(&claims, "token");
106            if str_claim(&claims, "sub").is_empty() || !is_access_token(token) {
107                return Err(Unauthenticated);
108            }
109            return Ok((user_from(&claims, token), true));
110        }
111
112        let token = bearer_token(headers).ok_or(Unauthenticated)?;
113        let claims = self
114            .jwks
115            .verify(
116                token,
117                &self.config.auth_server_issuer,
118                &self.config.audience,
119            )
120            .await
121            .map_err(|_| Unauthenticated)?;
122        // An ephemeral sign-in token is signed by the same key, so the type is what
123        // keeps it from passing for a session.
124        if str_claim(&claims, "typ") != "access" || str_claim(&claims, "sub").is_empty() {
125            return Err(Unauthenticated);
126        }
127        Ok((user_from(&claims, token), false))
128    }
129}
130
131/// Reads the type of the auth API token inside a session cookie. It was verified
132/// against the API's key set before the adapter signed it into the cookie, so its
133/// claims are read without verifying it again.
134fn is_access_token(token: &str) -> bool {
135    unverified_claims(token).is_some_and(|claims| str_claim(&claims, "typ") == "access")
136}
137
138fn user_from(claims: &Claims, token: &str) -> User {
139    let optional = |name: &str| {
140        Some(str_claim(claims, name))
141            .filter(|s| !s.is_empty())
142            .map(str::to_string)
143    };
144    User {
145        id: str_claim(claims, "sub").to_string(),
146        roles: claims
147            .get("roles")
148            .and_then(Value::as_array)
149            .map(|roles| {
150                roles
151                    .iter()
152                    .filter_map(Value::as_str)
153                    .map(str::to_string)
154                    .collect()
155            })
156            .unwrap_or_default(),
157        email: optional("email"),
158        phone: optional("phone"),
159        token: token.to_string(),
160    }
161}
162
163/// The layer [`Adapter::require_auth`] returns.
164#[derive(Clone)]
165pub struct RequireAuthLayer {
166    inner: Arc<Inner>,
167}
168
169impl fmt::Debug for RequireAuthLayer {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.debug_struct("RequireAuthLayer").finish_non_exhaustive()
172    }
173}
174
175impl<S> Layer<S> for RequireAuthLayer {
176    type Service = RequireAuth<S>;
177
178    fn layer(&self, service: S) -> Self::Service {
179        RequireAuth {
180            adapter: self.inner.clone(),
181            service,
182        }
183    }
184}
185
186/// The service [`RequireAuthLayer`] wraps around a handler.
187#[derive(Clone)]
188pub struct RequireAuth<S> {
189    adapter: Arc<Inner>,
190    service: S,
191}
192
193impl<S> fmt::Debug for RequireAuth<S> {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        f.debug_struct("RequireAuth").finish_non_exhaustive()
196    }
197}
198
199impl<S> Service<Request> for RequireAuth<S>
200where
201    S: Service<Request> + Clone + Send + 'static,
202    S::Response: IntoResponse,
203    S::Future: Send + 'static,
204{
205    type Response = Response;
206    type Error = S::Error;
207    type Future = Pin<Box<dyn Future<Output = Result<Response, S::Error>> + Send>>;
208
209    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
210        self.service.poll_ready(cx)
211    }
212
213    fn call(&mut self, mut req: Request) -> Self::Future {
214        // The clone has not been driven to readiness, so the ready one is the one
215        // that handles this request.
216        let clone = self.service.clone();
217        let mut service = std::mem::replace(&mut self.service, clone);
218        let adapter = self.adapter.clone();
219
220        Box::pin(async move {
221            match adapter.authenticate(req.headers()).await {
222                // A cookie session gets the same cross-site check as the auth
223                // routes, or a form on another site could act as the user.
224                Ok((_, true))
225                    if check_origin(&adapter.config, req.method(), req.headers()).is_some() =>
226                {
227                    Ok(json_response(
228                        StatusCode::FORBIDDEN,
229                        &json!({ "error": "cross_site_request_blocked" }),
230                    ))
231                }
232                Ok((user, _)) => {
233                    req.extensions_mut().insert(user);
234                    service.call(req).await.map(IntoResponse::into_response)
235                }
236                Err(rejected) => Ok(rejected.into_response()),
237            }
238        })
239    }
240}