1pub mod binding;
4pub mod conformance;
5pub mod deliberate;
6pub mod drafts;
7pub mod membership;
8pub mod membership_admission;
9pub mod membership_conformance;
10pub mod membership_pages;
11pub mod refusal;
12pub mod rights;
13#[cfg(feature = "http")]
14pub mod signin;
15pub mod verify;
16
17pub use membership::{GrantLog, MembershipDecision, MembershipRequest, Subject, Verdict};
18pub use refusal::Refusal;
19pub use rights::{Claims, Decision, Holder, Mode, Right, Target};
20pub use verify::{CLOCK_SKEW, KeySet, VerifiedPass};
21
22#[cfg(feature = "http")]
23use reqwest::Client as Http;
24use serde::{Deserialize, Serialize};
25#[cfg(feature = "http")]
26use url::Url;
27
28#[derive(Debug, thiserror::Error)]
30pub enum Error {
31 #[error("wrong_audience")]
33 WrongAudience,
34 #[error("wrong_issuer")]
36 WrongIssuer,
37 #[error("pass_expired")]
39 Expired,
40 #[error(
42 "pass_not_yet_valid: clock skew {skew_ms} ms exceeds {limit_ms} ms",
43 limit_ms = crate::verify::CLOCK_SKEW * 1000
44 )]
45 NotYetValid {
46 skew_ms: u128,
48 },
49 #[error("unpublished_key: {0}")]
51 UnpublishedKey(String),
52 #[error("signature_refused")]
54 Signature(#[source] ed25519_dalek::SignatureError),
55 #[error("pass_encoding_refused")]
57 Encoding(#[from] base64::DecodeError),
58 #[error("pass_contract_refused")]
60 Json(#[from] serde_json::Error),
61 #[error("rights_outside_audience")]
63 RightsOutsideAudience,
64 #[error("rights_truncated: {0}")]
66 Truncated(String),
67 #[error("contract_refused: {0}")]
69 Invalid(&'static str),
70 #[error("Lys could not be asked")]
74 Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
75 #[error("Lys could not be asked: {0}")]
77 CannotAsk(&'static str),
78 #[error("Lys could not be asked: token route answered {status}{suffix}", suffix = .refusal.as_deref().map_or_else(String::new, |name| format!(" {name}")))]
80 CannotAskToken {
81 status: u16,
83 refusal: Option<String>,
85 #[source]
87 cause: Option<Box<dyn std::error::Error + Send + Sync>>,
88 },
89 #[error("lys_origin_required: the Lys client needs an origin without an API mount or path")]
91 LysOriginRequired,
92 #[error("endpoint_refused")]
94 Url(#[from] url::ParseError),
95 #[error("{name}: {reason}")]
98 Binding {
99 name: &'static str,
101 reason: &'static str,
103 },
104 #[error("token_refused: {name}: {reason}")]
106 TokenRefused {
107 name: String,
109 reason: String,
111 },
112}
113
114impl Error {
115 #[must_use]
117 pub fn name(&self) -> &str {
118 match self {
119 Self::WrongAudience => "wrong_audience",
120 Self::WrongIssuer => "wrong_issuer",
121 Self::Expired => "pass_expired",
122 Self::NotYetValid { .. } => "pass_not_yet_valid",
123 Self::UnpublishedKey(_) => "unpublished_key",
124 Self::Signature(_) => "signature_refused",
125 Self::Encoding(_) => "pass_encoding_refused",
126 Self::Json(_) | Self::Invalid(_) => "contract_refused",
127 Self::RightsOutsideAudience => "rights_outside_audience",
128 Self::Truncated(_) => "rights_truncated",
129 Self::Transport(_) | Self::CannotAsk(_) | Self::CannotAskToken { .. } => {
130 "lys_could_not_be_asked"
131 }
132 Self::LysOriginRequired => "lys_origin_required",
133 Self::Url(_) => "endpoint_refused",
134 Self::Binding { name, .. } => name,
135 Self::TokenRefused { name, .. } => name,
136 }
137 }
138}
139
140#[cfg(feature = "http")]
142#[derive(Clone, Debug)]
143pub struct Client {
144 http: Http,
145 issuer: Url,
146}
147
148#[cfg(feature = "http")]
149impl Client {
150 pub fn new(http: Http, issuer: Url) -> Result<Self, Error> {
159 if !matches!(issuer.scheme(), "http" | "https")
160 || issuer.host_str().is_none()
161 || !issuer.username().is_empty()
162 || issuer.password().is_some()
163 || issuer.query().is_some()
164 || issuer.fragment().is_some()
165 {
166 return Err(Error::Invalid("issuer is not an HTTP origin"));
167 }
168 if issuer.path() != "/" {
169 return Err(Error::LysOriginRequired);
170 }
171 Ok(Self { http, issuer })
172 }
173
174 fn endpoint(&self, relative: &str) -> Result<Url, Error> {
175 Ok(self.issuer.join(relative)?)
176 }
177
178 fn api_endpoint(&self, relative: &str) -> Result<Url, Error> {
179 self.endpoint(&format!("api/{relative}"))
180 }
181
182 pub async fn fetch_keys(&self) -> Result<KeySet, Error> {
190 let response = self
191 .http
192 .get(self.endpoint("oauth/jwks")?)
193 .send()
194 .await
195 .map_err(|error| Error::Transport(Box::new(error)))?;
196 let text = response
197 .error_for_status()
198 .map_err(|error| Error::Transport(Box::new(error)))?
199 .text()
200 .await
201 .map_err(|error| Error::Transport(Box::new(error)))?;
202 KeySet::from_json(&text)
203 }
204
205 pub async fn fetch_token(&self, request: &TokenRequest<'_>) -> Result<TokenResponse, Error> {
215 self.token(&code_form(request)?).await
216 }
217
218 pub async fn fetch_bound_token(
227 &self,
228 request: &TokenRequest<'_>,
229 ) -> Result<TokenResponse, Error> {
230 let mut form = code_form(request)?.to_vec();
231 form.push(("grant_binding", binding::BINDING_REQUEST));
232 bound(self.token(&form).await?)
233 }
234
235 pub async fn refresh_bound_token(
242 &self,
243 client_id: &str,
244 client_secret: &str,
245 refresh_token: &str,
246 ) -> Result<TokenResponse, Error> {
247 if client_id.is_empty() || client_secret.is_empty() || refresh_token.is_empty() {
248 return Err(Error::Invalid("refresh request is incomplete"));
249 }
250 bound(
251 self.token(&[
252 ("grant_type", "refresh_token"),
253 ("client_id", client_id),
254 ("client_secret", client_secret),
255 ("refresh_token", refresh_token),
256 ("grant_binding", binding::BINDING_REQUEST),
257 ])
258 .await?,
259 )
260 }
261
262 pub async fn refresh_token(
272 &self,
273 client_id: &str,
274 client_secret: &str,
275 refresh_token: &str,
276 ) -> Result<TokenResponse, Error> {
277 if client_id.is_empty() || client_secret.is_empty() || refresh_token.is_empty() {
278 return Err(Error::Invalid("refresh request is incomplete"));
279 }
280 self.token(&[
281 ("grant_type", "refresh_token"),
282 ("client_id", client_id),
283 ("client_secret", client_secret),
284 ("refresh_token", refresh_token),
285 ])
286 .await
287 }
288
289 async fn token(&self, form: &[(&str, &str)]) -> Result<TokenResponse, Error> {
290 let response = self
291 .http
292 .post(self.endpoint("oauth/token")?)
293 .form(form)
294 .send()
295 .await
296 .map_err(|error| Error::Transport(Box::new(error)))?;
297 read_token(response, form).await
298 }
299}
300
301#[cfg(feature = "http")]
303fn code_form<'a>(request: &TokenRequest<'a>) -> Result<[(&'static str, &'a str); 6], Error> {
304 if request.client_id.is_empty()
305 || request.client_secret.is_empty()
306 || request.code.is_empty()
307 || request.redirect_uri.is_empty()
308 || request.code_verifier.is_empty()
309 {
310 return Err(Error::Invalid("token request is incomplete"));
311 }
312 Ok([
313 ("grant_type", "authorization_code"),
314 ("client_id", request.client_id),
315 ("client_secret", request.client_secret),
316 ("code", request.code),
317 ("redirect_uri", request.redirect_uri),
318 ("code_verifier", request.code_verifier),
319 ])
320}
321
322#[cfg(feature = "http")]
324fn bound(answer: TokenResponse) -> Result<TokenResponse, Error> {
325 binding::required(answer.grant_binding.as_deref())?;
326 Ok(answer)
327}
328
329#[cfg(feature = "http")]
333pub(crate) async fn read_token(
334 response: reqwest::Response,
335 form: &[(&str, &str)],
336) -> Result<TokenResponse, Error> {
337 let status = response.status();
338 if !status.is_success() {
339 let bytes = response
340 .bytes()
341 .await
342 .map_err(|error| Error::CannotAskToken {
343 status: status.as_u16(),
344 refusal: None,
345 cause: Some(Box::new(error.without_url())),
346 })?;
347 if let Ok(refused) = serde_json::from_slice::<TokenRefusal>(&bytes)
348 && !refused.error.is_empty()
349 && !refused.refusal.is_empty()
350 && refused.reason == refused.error_description
351 && !contains_credentials(&refused.refusal, form)
352 && !contains_credentials(&refused.error_description, form)
353 {
354 return Err(Error::TokenRefused {
355 name: refused.refusal,
356 reason: refused.error_description,
357 });
358 }
359 let refusal = serde_json::from_slice::<RouteRefusal>(&bytes)
360 .ok()
361 .and_then(|body| body.refusal)
362 .filter(|name| {
363 name.len() <= 128
364 && !contains_credentials(name, form)
365 && name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
366 && name
367 .bytes()
368 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
369 });
370 return Err(Error::CannotAskToken {
371 status: status.as_u16(),
372 refusal,
373 cause: None,
374 });
375 }
376 let answer: TokenResponse = response
377 .json()
378 .await
379 .map_err(|error| Error::Transport(Box::new(error)))?;
380 if answer.token_type != "Bearer" || answer.access_token.is_empty() || answer.expires_in == 0 {
381 return Err(Error::CannotAsk("invalid token response"));
382 }
383 Ok(answer)
384}
385
386#[cfg(feature = "http")]
387fn contains_credentials(text: &str, form: &[(&str, &str)]) -> bool {
388 form.iter().any(|(name, value)| {
389 matches!(
390 *name,
391 "client_secret" | "refresh_token" | "code" | "code_verifier"
392 ) && !value.is_empty()
393 && text.contains(*value)
394 })
395}
396
397pub struct TokenRequest<'a> {
399 pub client_id: &'a str,
401 pub client_secret: &'a str,
403 pub code: &'a str,
405 pub redirect_uri: &'a str,
407 pub code_verifier: &'a str,
409}
410
411#[derive(Deserialize, Serialize)]
413#[serde(deny_unknown_fields)]
414pub struct TokenResponse {
415 pub access_token: String,
417 pub token_type: String,
419 pub expires_in: u64,
421 pub id_token: Option<String>,
423 pub refresh_token: Option<String>,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub grant_binding: Option<String>,
429}
430
431#[cfg(feature = "http")]
432#[derive(Deserialize)]
433#[serde(deny_unknown_fields)]
434struct TokenRefusal {
435 error: String,
436 error_description: String,
437 refusal: String,
438 reason: String,
439}
440
441#[cfg(feature = "http")]
442#[derive(Deserialize)]
443struct RouteRefusal {
444 refusal: Option<String>,
445}