Skip to main content

edda_auth/
server.rs

1//! For services: issuing and checking tokens, and an axum extractor.
2
3use std::{sync::Arc, time::Duration};
4
5use axum::{
6    Json,
7    extract::FromRequestParts,
8    http::{StatusCode, header, request::Parts},
9    response::{IntoResponse, Response},
10};
11use pasetors::{
12    Public,
13    claims::{Claims as PasetoClaims, ClaimsValidationRules},
14    keys::{AsymmetricPublicKey, AsymmetricSecretKey},
15    public,
16    token::UntrustedToken,
17    version4::V4,
18};
19
20use crate::{AuthError, DeviceKey, PublicKey, protocol::Claims};
21
22/// Bound into every token's signature, so these tokens can't be confused
23/// with PASETO tokens minted by anything else using the same key.
24const IMPLICIT_ASSERTION: &[u8] = b"edda/v1/token";
25const CLAIMS_KEY: &str = "edda";
26
27/// Mints tokens. Only the auth service holds one.
28pub struct TokenSigner {
29    secret: AsymmetricSecretKey<V4>,
30    public: PublicKey,
31}
32
33impl TokenSigner {
34    /// Signs tokens with `key`.
35    pub fn new(key: &DeviceKey) -> Self {
36        Self {
37            secret: AsymmetricSecretKey::from(&key.keypair_bytes()).expect("64-byte keypair"),
38            public: key.public(),
39        }
40    }
41
42    /// The key its tokens can be checked with.
43    pub fn public_key(&self) -> PublicKey {
44        self.public
45    }
46
47    /// A token for `claims`, valid until `claims.expires_at`.
48    pub fn issue(&self, claims: &Claims) -> Result<String, AuthError> {
49        let lifetime = (claims.expires_at - chrono::Utc::now())
50            .to_std()
51            .unwrap_or(Duration::ZERO);
52        let encoding = |e: pasetors::errors::Error| AuthError::Encoding(e.to_string());
53        let mut paseto = PasetoClaims::new_expires_in(&lifetime).map_err(encoding)?;
54        let ours = serde_json::to_value(claims).map_err(|e| AuthError::Encoding(e.to_string()))?;
55        paseto.add_additional(CLAIMS_KEY, ours).map_err(encoding)?;
56        public::sign(&self.secret, &paseto, None, Some(IMPLICIT_ASSERTION)).map_err(encoding)
57    }
58}
59
60/// Checks tokens. Needs only the auth service's public key.
61pub struct TokenVerifier {
62    public: AsymmetricPublicKey<V4>,
63}
64
65impl TokenVerifier {
66    /// Accepts tokens signed with `key`.
67    pub fn new(key: PublicKey) -> Self {
68        Self {
69            public: AsymmetricPublicKey::from(key.as_bytes()).expect("32-byte key"),
70        }
71    }
72
73    /// The claims of a genuine, unexpired token.
74    pub fn verify(&self, token: &str) -> Result<Claims, AuthError> {
75        let untrusted =
76            UntrustedToken::<Public, V4>::try_from(token).map_err(|_| AuthError::InvalidToken)?;
77        // Checks the signature, then `exp`, `nbf` and `iat`.
78        let trusted = public::verify(
79            &self.public,
80            &untrusted,
81            &ClaimsValidationRules::new(),
82            None,
83            Some(IMPLICIT_ASSERTION),
84        )
85        .map_err(|_| AuthError::InvalidToken)?;
86        let ours = trusted
87            .payload_claims()
88            .and_then(|c| c.get_claim(CLAIMS_KEY))
89            .ok_or(AuthError::InvalidToken)?;
90        serde_json::from_value(ours.clone()).map_err(|_| AuthError::InvalidToken)
91    }
92}
93
94/// The verified claims of the request's bearer token. Requires the
95/// [`TokenVerifier`] as an `Extension<Arc<TokenVerifier>>` layer. A
96/// read-only campaign's token is refused for anything but reading.
97pub struct Authenticated(pub Claims);
98
99impl<S: Send + Sync> FromRequestParts<S> for Authenticated {
100    type Rejection = Refused;
101
102    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
103        let verifier = parts
104            .extensions
105            .get::<Arc<TokenVerifier>>()
106            .expect("TokenVerifier extension is installed");
107        let token = parts
108            .headers
109            .get(header::AUTHORIZATION)
110            .and_then(|v| v.to_str().ok())
111            .and_then(|v| v.strip_prefix("Bearer "))
112            .ok_or(Refused::Unauthorized("a bearer token is required"))?;
113        let claims = verifier
114            .verify(token.trim())
115            .map_err(|_| Refused::Unauthorized("invalid or expired token"))?;
116        // Whose the request is, in the request's log span.
117        tracing::Span::current().record("campaign", tracing::field::display(claims.campaign));
118        if claims.settings.read_only && !parts.method.is_safe() {
119            return Err(Refused::ReadOnly);
120        }
121        Ok(Authenticated(claims))
122    }
123}
124
125/// Why a request's token isn't enough.
126#[derive(Debug, PartialEq, Eq)]
127pub enum Refused {
128    /// A `401`: no token, or not a valid one.
129    Unauthorized(&'static str),
130    /// A `403`: the campaign is read-only, and this would change it.
131    ReadOnly,
132}
133
134/// What a read-only campaign's devices are told when they try to change it.
135pub const READ_ONLY: &str = "this campaign is read-only for now; nothing in it can be changed";
136
137impl IntoResponse for Refused {
138    fn into_response(self) -> Response {
139        match self {
140            Refused::Unauthorized(why) => (
141                StatusCode::UNAUTHORIZED,
142                [(header::WWW_AUTHENTICATE, "Bearer")],
143                Json(serde_json::json!({ "error": why })),
144            )
145                .into_response(),
146            Refused::ReadOnly => (
147                StatusCode::FORBIDDEN,
148                Json(serde_json::json!({ "error": READ_ONLY })),
149            )
150                .into_response(),
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use chrono::Utc;
158    use uuid::Uuid;
159
160    use super::*;
161    use crate::protocol::Principal;
162
163    fn claims(lifetime: chrono::Duration) -> Claims {
164        Claims {
165            key: DeviceKey::generate().public(),
166            campaign: Uuid::new_v4(),
167            role: Principal::Player,
168            name: "Vex's laptop".into(),
169            edition: edda_types::Edition::default(),
170            settings: crate::protocol::CampaignSettings::default(),
171            expires_at: Utc::now() + lifetime,
172        }
173    }
174
175    #[test]
176    fn tokens_verify_only_with_the_right_key_and_until_they_expire() {
177        let signer = TokenSigner::new(&DeviceKey::generate());
178        let verifier = TokenVerifier::new(signer.public_key());
179        let claims = claims(chrono::Duration::minutes(15));
180        let token = signer.issue(&claims).unwrap();
181        assert!(token.starts_with("v4.public."));
182        assert_eq!(verifier.verify(&token), Ok(claims.clone()));
183
184        let stranger = TokenVerifier::new(DeviceKey::generate().public());
185        assert_eq!(stranger.verify(&token), Err(AuthError::InvalidToken));
186
187        let mut tampered = token.clone();
188        let last = tampered.pop().unwrap();
189        tampered.push(if last == 'A' { 'B' } else { 'A' });
190        assert_eq!(verifier.verify(&tampered), Err(AuthError::InvalidToken));
191
192        let expired = signer
193            .issue(&self::claims(chrono::Duration::seconds(-5)))
194            .unwrap();
195        assert_eq!(verifier.verify(&expired), Err(AuthError::InvalidToken));
196    }
197}