Skip to main content

lys_pass/
lib.rs

1//! Shared pass verification and permission contracts for products.
2
3pub 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/// A named refusal at a token, key or live-contract boundary.
29#[derive(Debug, thiserror::Error)]
30pub enum Error {
31    /// The signed audience differs from the product.
32    #[error("wrong_audience")]
33    WrongAudience,
34    /// The signed issuer differs from the trusted issuer.
35    #[error("wrong_issuer")]
36    WrongIssuer,
37    /// The pass has reached its expiry.
38    #[error("pass_expired")]
39    Expired,
40    /// The pass starts beyond the permitted future clock skew.
41    #[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        /// The greatest future issue or not-before offset in milliseconds.
47        skew_ms: u128,
48    },
49    /// No published verification key has this id.
50    #[error("unpublished_key: {0}")]
51    UnpublishedKey(String),
52    /// Strict signature verification failed.
53    #[error("signature_refused")]
54    Signature(#[source] ed25519_dalek::SignatureError),
55    /// A compact token segment was not canonical base64url.
56    #[error("pass_encoding_refused")]
57    Encoding(#[from] base64::DecodeError),
58    /// A signed claim or published key could not be read.
59    #[error("pass_contract_refused")]
60    Json(#[from] serde_json::Error),
61    /// A pass contains another application's rights.
62    #[error("rights_outside_audience")]
63    RightsOutsideAudience,
64    /// The issuer omitted rights for this application.
65    #[error("rights_truncated: {0}")]
66    Truncated(String),
67    /// A boundary value violates the published contract.
68    #[error("contract_refused: {0}")]
69    Invalid(&'static str),
70    /// A live request failed before a contract answer was received. The
71    /// transport's own error, typed by no HTTP stack, so the variant stands
72    /// whether or not the `http` feature is on.
73    #[error("Lys could not be asked")]
74    Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
75    /// A live response is outside the permission contract.
76    #[error("Lys could not be asked: {0}")]
77    CannotAsk(&'static str),
78    /// The token route answered outside its refusal contract.
79    #[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        /// The HTTP status the route answered.
82        status: u16,
83        /// A plain refusal name, when its body carried one.
84        refusal: Option<String>,
85        /// A failure reading the answer, without the response body.
86        #[source]
87        cause: Option<Box<dyn std::error::Error + Send + Sync>>,
88    },
89    /// A live client needs the Lys origin, without an API mount or other path.
90    #[error("lys_origin_required: the Lys client needs an origin without an API mount or path")]
91    LysOriginRequired,
92    /// A supplied endpoint is malformed.
93    #[error("endpoint_refused")]
94    Url(#[from] url::ParseError),
95    /// A pass's grant binding is absent, unsupported or not the pass's
96    /// (DIRECTORY-089 R2), by its stable name.
97    #[error("{name}: {reason}")]
98    Binding {
99        /// The binding's stable refusal name.
100        name: &'static str,
101        /// Its words.
102        reason: &'static str,
103    },
104    /// A named issuer refusal of the exchange or refresh.
105    #[error("token_refused: {name}: {reason}")]
106    TokenRefused {
107        /// The issuer's stable refusal name.
108        name: String,
109        /// The issuer's refusal words.
110        reason: String,
111    },
112}
113
114impl Error {
115    /// The stable machine name, without keys, credentials or response bodies.
116    #[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/// A transport supplied by the product, holding no token or credential.
141#[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    /// Use the product's configured transport and trusted Lys origin.
151    /// Provider routes live at its root; product API routes live under `/api`.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`Error::Invalid`] unless the issuer is HTTP or HTTPS with
156    /// a host and no credentials, query or fragment. A path other than the
157    /// root is [`Error::LysOriginRequired`].
158    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    /// Fetch the issuer's current keyset explicitly; offline checks never fetch.
183    ///
184    /// # Errors
185    ///
186    /// Preserves endpoint, transport, refusal-status and response-reading
187    /// failures. A fetched keyset that does not satisfy [`KeySet::from_json`]
188    /// is refused before a replacement cache is returned.
189    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    /// Exchange an authorization code using credentials owned by the caller.
206    ///
207    /// # Errors
208    ///
209    /// Refuses a missing client id, secret, code, redirect URI or PKCE
210    /// verifier before sending. Preserves endpoint and transport failures
211    /// and the issuer's named token refusal. An unreadable or invalid token
212    /// answer, or a failure status outside the refusal contract, is returned
213    /// as Lys could not be asked.
214    pub async fn fetch_token(&self, request: &TokenRequest<'_>) -> Result<TokenResponse, Error> {
215        self.token(&code_form(request)?).await
216    }
217
218    /// [`Client::fetch_token`], asking Lys for the pass's grant binding
219    /// beside it (DIRECTORY-089 R2). An answer without one is refused
220    /// `grant_binding_required`, never taken as a pass at revision zero.
221    ///
222    /// # Errors
223    ///
224    /// Returns the exchange refusals of [`Self::fetch_token`], and
225    /// `grant_binding_required` when the answer has no nonempty binding.
226    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    /// [`Client::refresh_token`], asking for the new pass's grant binding.
236    ///
237    /// # Errors
238    ///
239    /// Returns the refresh refusals of [`Self::refresh_token`], and
240    /// `grant_binding_required` when the new answer has no nonempty binding.
241    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    /// Ask Lys to refresh from live grants; no timer or cached authority is used.
263    ///
264    /// # Errors
265    ///
266    /// Refuses a missing client id, secret or refresh token before sending.
267    /// Preserves endpoint and transport failures and the issuer's named
268    /// token refusal, including withdrawn live authority. An unreadable or
269    /// invalid token answer, or a failure status outside the refusal
270    /// contract, is returned as Lys could not be asked.
271    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/// The authorization-code exchange form of `request`.
302#[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/// `answer`, refused by name when it carries no grant binding.
323#[cfg(feature = "http")]
324fn bound(answer: TokenResponse) -> Result<TokenResponse, Error> {
325    binding::required(answer.grant_binding.as_deref())?;
326    Ok(answer)
327}
328
329/// A token endpoint's answer: the pass, or the issuer's refusal by name. A
330/// refusal without a name is not a contract answer, and neither carries the
331/// response body into an error.
332#[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
397/// Borrowed authorization-code exchange inputs, never retained by the client.
398pub struct TokenRequest<'a> {
399    /// The registered audience.
400    pub client_id: &'a str,
401    /// The registered client credential.
402    pub client_secret: &'a str,
403    /// The one-use authorization code.
404    pub code: &'a str,
405    /// The registered return address.
406    pub redirect_uri: &'a str,
407    /// The authorization's PKCE verifier.
408    pub code_verifier: &'a str,
409}
410
411/// A token response owned by the product that requested it.
412#[derive(Deserialize, Serialize)]
413#[serde(deny_unknown_fields)]
414pub struct TokenResponse {
415    /// The signed access pass.
416    pub access_token: String,
417    /// The authorization scheme.
418    pub token_type: String,
419    /// The remaining lifetime in seconds.
420    pub expires_in: u64,
421    /// The identity assertion, when the exchange includes it.
422    pub id_token: Option<String>,
423    /// The credential used for a live refresh, when issued.
424    pub refresh_token: Option<String>,
425    /// The pass's signed grant binding, present only when it was asked for
426    /// (DIRECTORY-089 R2).
427    #[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}