Skip to main content

auth_service/
routes.rs

1use std::sync::Arc;
2
3use axum::{
4    Json, Router,
5    extract::{Path, State},
6    http::StatusCode,
7    routing::{delete, get, post},
8};
9use chrono::{Duration, Utc};
10use edda_auth::{
11    PublicKey, Purpose, Role, SignedCert,
12    protocol::{
13        Campaign, Challenge, ChallengeRequest, Claims, Device, LoginProof, LoginRequest, ServerKey,
14        Session, SetupCode, SetupRequest,
15    },
16    server::{Authenticated, TokenSigner},
17};
18
19use edda_server::{Limits, openapi::ErrorBody};
20
21use crate::{error::ApiError, store::Store};
22
23const TOKEN_LIFETIME: Duration = Duration::minutes(15);
24const CHALLENGE_LIFETIME: Duration = Duration::minutes(1);
25const MAX_NAME_LENGTH: usize = 64;
26
27/// What the handlers share.
28pub struct AppState {
29    pub store: Store,
30    pub signer: TokenSigner,
31    pub limits: Limits,
32}
33
34type AppStateRef = Arc<AppState>;
35
36/// The routes, signing in limited by `limits`.
37pub fn router(limits: &Limits) -> Router<AppStateRef> {
38    // What needs no token gets fewer tries.
39    let sign_ins = Router::new()
40        .route("/setup", post(setup))
41        .route("/setup/lookup", post(lookup_setup_code))
42        .route("/login/challenge", post(challenge))
43        .route("/login", post(login));
44    Router::new()
45        .route("/health", get(edda_server::health))
46        .route("/public-key", get(public_key))
47        .merge(limits.guard_sign_ins(sign_ins))
48        .route("/campaign", get(campaign))
49        .route("/devices", get(devices).post(add_device))
50        .route("/devices/{key}", delete(revoke))
51}
52
53#[utoipa::path(
54    get,
55    path = "/public-key",
56    tag = "auth",
57    security(()),
58    responses((status = 200, description = "The key tokens are signed with.", body = ServerKey))
59)]
60async fn public_key(State(state): State<AppStateRef>) -> Json<ServerKey> {
61    Json(ServerKey {
62        key: state.signer.public_key(),
63    })
64}
65
66#[utoipa::path(
67    post,
68    path = "/setup/lookup",
69    tag = "auth",
70    security(()),
71    request_body = SetupCode,
72    responses((status = 200, description = "The campaign it's for.", body = Campaign), (status = 401, description = "Not a valid setup code, or it has expired.", body = ErrorBody))
73)]
74/// Which campaign a setup code is for, without using it up: the DM's
75/// device needs the campaign id to sign its certificate.
76async fn lookup_setup_code(
77    State(state): State<AppStateRef>,
78    Json(request): Json<SetupCode>,
79) -> Result<Json<Campaign>, ApiError> {
80    let campaign = state
81        .store
82        .peek_setup_code(&request.code)
83        .await?
84        .ok_or_else(|| ApiError::Unauthorized("invalid or expired setup code".into()))?;
85    Ok(Json(state.store.campaign(campaign).await?))
86}
87
88#[utoipa::path(
89    post,
90    path = "/setup",
91    tag = "auth",
92    security(()),
93    request_body = SetupRequest,
94    responses(
95        (status = 201, description = "The device is the campaign's DM.", body = Campaign),
96        (status = 400, description = "The certificate isn't a DM's, self-signed, for the code's campaign.", body = ErrorBody),
97        (status = 401, description = "Not a valid setup code, or it has expired.", body = ErrorBody),
98    )
99)]
100/// Registers a campaign's first (or, after recovery, a new) DM device.
101async fn setup(
102    State(state): State<AppStateRef>,
103    Json(request): Json<SetupRequest>,
104) -> Result<(StatusCode, Json<Campaign>), ApiError> {
105    let cert = request.cert.verify()?;
106    if !request.cert.is_self_signed() || cert.role != Role::Dm {
107        return Err(ApiError::BadRequest(
108            "setup needs a self-signed DM certificate".into(),
109        ));
110    }
111    check_name(&cert.name)?;
112    let mut tx = state.store.pool().begin().await?;
113    let campaign = state
114        .store
115        .take_setup_code(&mut *tx, &request.code)
116        .await?
117        .ok_or_else(|| ApiError::Unauthorized("invalid or expired setup code".into()))?;
118    if campaign != cert.campaign {
119        return Err(ApiError::BadRequest(
120            "certificate is for a different campaign".into(),
121        ));
122    }
123    state.store.add_device(&mut *tx, &request.cert).await?;
124    tx.commit().await?;
125    Ok((
126        StatusCode::CREATED,
127        Json(state.store.campaign(campaign).await?),
128    ))
129}
130
131#[utoipa::path(
132    post,
133    path = "/login/challenge",
134    tag = "auth",
135    security(()),
136    request_body = ChallengeRequest,
137    responses(
138        (status = 200, description = "What to sign; valid for a minute, once.", body = Challenge),
139        (status = 401, description = "Not a device of that campaign.", body = ErrorBody),
140    )
141)]
142async fn challenge(
143    State(state): State<AppStateRef>,
144    Json(request): Json<ChallengeRequest>,
145) -> Result<Json<Challenge>, ApiError> {
146    if state
147        .store
148        .principal(request.campaign, request.key)
149        .await?
150        .is_none()
151    {
152        return Err(ApiError::Unauthorized(
153            "this key is not registered in the campaign".into(),
154        ));
155    }
156    let challenge = state
157        .store
158        .create_challenge(
159            request.campaign,
160            request.key,
161            state.signer.public_key(),
162            CHALLENGE_LIFETIME,
163        )
164        .await?;
165    Ok(Json(challenge))
166}
167
168#[utoipa::path(
169    post,
170    path = "/login",
171    tag = "auth",
172    security(()),
173    request_body = LoginRequest,
174    responses(
175        (status = 200, description = "A token, valid for 15 minutes.", body = Session),
176        (status = 401, description = "Not a valid signature of a current challenge.", body = ErrorBody),
177    )
178)]
179async fn login(
180    State(state): State<AppStateRef>,
181    Json(request): Json<LoginRequest>,
182) -> Result<Json<Session>, ApiError> {
183    let rejected = || ApiError::Unauthorized("invalid or expired challenge".into());
184    let issued = state
185        .store
186        .take_challenge(request.challenge)
187        .await?
188        .filter(|c| c.expires_at > Utc::now())
189        .ok_or_else(rejected)?;
190    let proof = LoginProof {
191        challenge: request.challenge,
192        nonce: issued.nonce,
193        campaign: issued.campaign,
194        server: state.signer.public_key(),
195    };
196    issued
197        .key
198        .verify(Purpose::Login, &proof, &request.signature)?;
199    // Checked again: the device may have been revoked since the challenge.
200    let (role, name) = state
201        .store
202        .principal(issued.campaign, issued.key)
203        .await?
204        .ok_or_else(rejected)?;
205    let edition = state.store.campaign(issued.campaign).await?.edition;
206    let settings = state.store.settings(issued.campaign).await?;
207    let claims = Claims {
208        key: issued.key,
209        campaign: issued.campaign,
210        role,
211        name,
212        edition,
213        settings,
214        expires_at: Utc::now() + TOKEN_LIFETIME,
215    };
216    let token = state.signer.issue(&claims)?;
217    Ok(Json(Session { token, claims }))
218}
219
220#[utoipa::path(
221    get,
222    path = "/campaign",
223    tag = "auth",
224    responses((status = 200, description = "The token's campaign.", body = Campaign))
225)]
226async fn campaign(
227    State(state): State<AppStateRef>,
228    Authenticated(claims): Authenticated,
229) -> Result<Json<Campaign>, ApiError> {
230    Ok(Json(state.store.campaign(claims.campaign).await?))
231}
232
233#[utoipa::path(
234    get,
235    path = "/devices",
236    tag = "devices",
237    responses((status = 200, description = "Every device of the campaign, revoked ones too, with its certificate.", body = Vec<Device>))
238)]
239async fn devices(
240    State(state): State<AppStateRef>,
241    Authenticated(claims): Authenticated,
242) -> Result<Json<Vec<Device>>, ApiError> {
243    Ok(Json(state.store.devices(claims.campaign).await?))
244}
245
246#[utoipa::path(
247    post,
248    path = "/devices",
249    tag = "devices",
250    request_body(content = SignedCert, description = "A certificate for the device, signed by this DM device."),
251    responses(
252        (status = 201, description = "Added.", body = Device),
253        (status = 400, description = "Not signed by this device, or not for this campaign.", body = ErrorBody),
254        (status = 403, description = "Not a DM device, or the campaign has as many devices as it may.", body = ErrorBody),
255        (status = 409, description = "The key is in the campaign already.", body = ErrorBody),
256    )
257)]
258/// A DM device vouches for a new device with a certificate it signed.
259async fn add_device(
260    State(state): State<AppStateRef>,
261    Authenticated(claims): Authenticated,
262    Json(signed): Json<SignedCert>,
263) -> Result<(StatusCode, Json<Device>), ApiError> {
264    require_dm(&claims)?;
265    let cert = signed.verify()?;
266    if cert.issuer != claims.key {
267        return Err(ApiError::BadRequest(
268            "the certificate must be signed by the device adding it".into(),
269        ));
270    }
271    if cert.campaign != claims.campaign {
272        return Err(ApiError::BadRequest(
273            "certificate is for a different campaign".into(),
274        ));
275    }
276    check_name(&cert.name)?;
277    let devices = state.store.devices(claims.campaign).await?;
278    let active = devices.iter().filter(|d| d.revoked_at.is_none()).count();
279    let active = i64::try_from(active).unwrap_or(i64::MAX);
280    let limits = state.limits.with(&claims.settings.quotas);
281    limits
282        .room_for(active, 1, limits.devices, "devices")
283        .map_err(ApiError::Forbidden)?;
284    let device = state.store.add_device(state.store.pool(), &signed).await?;
285    Ok((StatusCode::CREATED, Json(device)))
286}
287
288#[utoipa::path(
289    delete,
290    path = "/devices/{key}",
291    tag = "devices",
292    params(("key" = String, Path, description = "The device's key.")),
293    responses(
294        (status = 200, description = "Revoked: it can't log in any more.", body = Device),
295        (status = 400, description = "Not a device key.", body = ErrorBody),
296        (status = 403, description = "Not a DM device.", body = ErrorBody),
297        (status = 404, description = "No such device in the campaign.", body = ErrorBody),
298        (status = 422, description = "The campaign's last DM device.", body = ErrorBody),
299    )
300)]
301async fn revoke(
302    State(state): State<AppStateRef>,
303    Authenticated(claims): Authenticated,
304    Path(key): Path<String>,
305) -> Result<Json<Device>, ApiError> {
306    require_dm(&claims)?;
307    let key: PublicKey = key
308        .parse()
309        .map_err(|e| ApiError::BadRequest(format!("bad key: {e}")))?;
310    Ok(Json(state.store.revoke(claims.campaign, key).await?))
311}
312
313fn require_dm(claims: &Claims) -> Result<(), ApiError> {
314    if claims.is_dm() {
315        Ok(())
316    } else {
317        Err(ApiError::Forbidden("only a DM device can do this".into()))
318    }
319}
320
321fn check_name(name: &str) -> Result<(), ApiError> {
322    let name = name.trim();
323    if name.is_empty() || name.chars().count() > MAX_NAME_LENGTH {
324        return Err(ApiError::BadRequest(format!(
325            "device names must be 1 to {MAX_NAME_LENGTH} characters"
326        )));
327    }
328    Ok(())
329}