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
27pub struct AppState {
29 pub store: Store,
30 pub signer: TokenSigner,
31 pub limits: Limits,
32}
33
34type AppStateRef = Arc<AppState>;
35
36pub fn router(limits: &Limits) -> Router<AppStateRef> {
38 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)]
74async 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)]
100async 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 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)]
258async 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}