Skip to main content

edda_client/
auth.rs

1//! Client for the auth service: setting up devices and logging in.
2
3use edda_auth::{
4    DeviceCert, DeviceKey, PublicKey, Purpose, Role, SignedCert,
5    protocol::{
6        Campaign, Challenge, ChallengeRequest, Device, LoginProof, LoginRequest, ServerKey,
7        Session, SetupCode, SetupRequest,
8    },
9};
10use uuid::Uuid;
11
12use crate::{ClientError, check, send};
13
14/// The auth service's client.
15#[derive(Debug, Clone)]
16pub struct AuthClient {
17    base_url: String,
18    pub(crate) http: reqwest::Client,
19}
20
21impl AuthClient {
22    /// A client for the auth service at `base_url`, without a token.
23    pub fn new(base_url: impl Into<String>) -> Self {
24        Self {
25            base_url: base_url.into().trim_end_matches('/').to_string(),
26            http: crate::http_client(None),
27        }
28    }
29
30    /// For the calls that need a logged-in device (see [`Session::token`]).
31    #[must_use]
32    pub fn with_token(mut self, token: &str) -> Self {
33        self.http = crate::http_client(Some(token));
34        self
35    }
36
37    /// The key the server signs tokens with. Clients remember it the first
38    /// time and check it on every login after.
39    pub async fn server_key(&self) -> Result<PublicKey, ClientError> {
40        let key: ServerKey = send(self.http.get(self.url("/public-key"))).await?;
41        Ok(key.key)
42    }
43
44    /// [`AuthClient::server_key`], retrying while the auth service can't be
45    /// reached (e.g. it's still starting), for up to `patience`.
46    pub async fn wait_for_server_key(
47        &self,
48        patience: std::time::Duration,
49    ) -> Result<PublicKey, ClientError> {
50        let give_up = tokio::time::Instant::now() + patience;
51        loop {
52            match self.server_key().await {
53                Err(ClientError::Http(_)) if tokio::time::Instant::now() < give_up => {
54                    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
55                }
56                result => return result,
57            }
58        }
59    }
60
61    /// Makes `key` the DM of the campaign a one-time setup code is for.
62    pub async fn setup(
63        &self,
64        code: &str,
65        key: &DeviceKey,
66        name: &str,
67    ) -> Result<Campaign, ClientError> {
68        let code = SetupCode {
69            code: code.trim().to_string(),
70        };
71        let campaign: Campaign =
72            send(self.http.post(self.url("/setup/lookup")).json(&code)).await?;
73        let cert = DeviceCert::self_signed(key, campaign.id, Role::Dm, name)
74            .map_err(|e| ClientError::Rejected(e.to_string()))?;
75        let request = SetupRequest {
76            code: code.code,
77            cert,
78        };
79        send(self.http.post(self.url("/setup")).json(&request)).await
80    }
81
82    /// Answers a login challenge with `key`, returning a short-lived token.
83    /// With `expected_server`, refuses to sign for any other server.
84    pub async fn login(
85        &self,
86        campaign: Uuid,
87        key: &DeviceKey,
88        expected_server: Option<PublicKey>,
89    ) -> Result<Session, ClientError> {
90        let request = ChallengeRequest {
91            campaign,
92            key: key.public(),
93        };
94        let challenge: Challenge =
95            send(self.http.post(self.url("/login/challenge")).json(&request)).await?;
96        if challenge.campaign != campaign
97            || expected_server.is_some_and(|expected| expected != challenge.server)
98        {
99            return Err(ClientError::Unauthorized(
100                "the server's key is not the one this device knows; refusing to log in".into(),
101            ));
102        }
103        let signature = key
104            .sign(Purpose::Login, &LoginProof::from(&challenge))
105            .map_err(|e| ClientError::Rejected(e.to_string()))?;
106        let answer = LoginRequest {
107            challenge: challenge.id,
108            signature,
109        };
110        send(self.http.post(self.url("/login")).json(&answer)).await
111    }
112
113    /// The token's campaign.
114    pub async fn campaign(&self) -> Result<Campaign, ClientError> {
115        send(self.http.get(self.url("/campaign"))).await
116    }
117
118    /// The campaign's devices, revoked ones too.
119    pub async fn devices(&self) -> Result<Vec<Device>, ClientError> {
120        send(self.http.get(self.url("/devices"))).await
121    }
122
123    /// Adds a device, vouched for by a certificate signed with the DM key
124    /// this client is logged in with.
125    pub async fn add_device(&self, cert: &SignedCert) -> Result<Device, ClientError> {
126        send(self.http.post(self.url("/devices")).json(cert)).await
127    }
128
129    /// Revokes a device (DM devices only).
130    pub async fn revoke(&self, key: PublicKey) -> Result<Device, ClientError> {
131        let url = self.url(&format!("/devices/{key}"));
132        let response = check(self.http.delete(url).send().await?).await?;
133        response.json().await.map_err(ClientError::InvalidResponse)
134    }
135
136    pub(crate) fn url(&self, path: &str) -> String {
137        format!("{}{path}", self.base_url)
138    }
139}