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::{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 in the future.
41    #[error("pass_not_yet_valid")]
42    NotYetValid,
43    /// No published verification key has this id.
44    #[error("unpublished_key: {0}")]
45    UnpublishedKey(String),
46    /// Strict signature verification failed.
47    #[error("signature_refused")]
48    Signature(#[source] ed25519_dalek::SignatureError),
49    /// A compact token segment was not canonical base64url.
50    #[error("pass_encoding_refused")]
51    Encoding(#[from] base64::DecodeError),
52    /// A signed claim or published key could not be read.
53    #[error("pass_contract_refused")]
54    Json(#[from] serde_json::Error),
55    /// A pass contains another application's rights.
56    #[error("rights_outside_audience")]
57    RightsOutsideAudience,
58    /// The issuer omitted rights for this application.
59    #[error("rights_truncated: {0}")]
60    Truncated(String),
61    /// A boundary value violates the published contract.
62    #[error("contract_refused: {0}")]
63    Invalid(&'static str),
64    /// A live request failed before a contract answer was received. The
65    /// transport's own error, typed by no HTTP stack, so the variant stands
66    /// whether or not the `http` feature is on.
67    #[error("Lys could not be asked")]
68    Transport(#[source] Box<dyn std::error::Error + Send + Sync>),
69    /// A live response is outside the permission contract.
70    #[error("Lys could not be asked: {0}")]
71    CannotAsk(&'static str),
72    /// A supplied endpoint is malformed.
73    #[error("endpoint_refused")]
74    Url(#[from] url::ParseError),
75    /// A pass's grant binding is absent, unsupported or not the pass's
76    /// (DIRECTORY-089 R2), by its stable name.
77    #[error("{name}: {reason}")]
78    Binding {
79        /// The binding's stable refusal name.
80        name: &'static str,
81        /// Its words.
82        reason: &'static str,
83    },
84    /// A named issuer refusal of the exchange or refresh.
85    #[error("token_refused: {name}: {reason}")]
86    TokenRefused {
87        /// The issuer's stable refusal name.
88        name: String,
89        /// The issuer's refusal words.
90        reason: String,
91    },
92}
93
94impl Error {
95    /// The stable machine name, without keys, credentials or response bodies.
96    #[must_use]
97    pub fn name(&self) -> &str {
98        match self {
99            Self::WrongAudience => "wrong_audience",
100            Self::WrongIssuer => "wrong_issuer",
101            Self::Expired => "pass_expired",
102            Self::NotYetValid => "pass_not_yet_valid",
103            Self::UnpublishedKey(_) => "unpublished_key",
104            Self::Signature(_) => "signature_refused",
105            Self::Encoding(_) => "pass_encoding_refused",
106            Self::Json(_) | Self::Invalid(_) => "contract_refused",
107            Self::RightsOutsideAudience => "rights_outside_audience",
108            Self::Truncated(_) => "rights_truncated",
109            Self::Transport(_) | Self::CannotAsk(_) => "lys_could_not_be_asked",
110            Self::Url(_) => "endpoint_refused",
111            Self::Binding { name, .. } => name,
112            Self::TokenRefused { name, .. } => name,
113        }
114    }
115}
116
117/// A transport supplied by the product, holding no token or credential.
118#[cfg(feature = "http")]
119#[derive(Clone, Debug)]
120pub struct Client {
121    http: Http,
122    issuer: Url,
123}
124
125#[cfg(feature = "http")]
126impl Client {
127    /// Use the product's configured transport and trusted Lys issuer.
128    pub fn new(http: Http, mut issuer: Url) -> Result<Self, Error> {
129        if !matches!(issuer.scheme(), "http" | "https")
130            || issuer.host_str().is_none()
131            || !issuer.username().is_empty()
132            || issuer.password().is_some()
133            || issuer.query().is_some()
134            || issuer.fragment().is_some()
135        {
136            return Err(Error::Invalid("issuer is not an HTTP origin"));
137        }
138        if !issuer.path().ends_with('/') {
139            issuer.set_path(&format!("{}/", issuer.path()));
140        }
141        Ok(Self { http, issuer })
142    }
143
144    fn endpoint(&self, relative: &str) -> Result<Url, Error> {
145        Ok(self.issuer.join(relative)?)
146    }
147
148    /// Fetch the issuer's current keyset explicitly; offline checks never fetch.
149    pub async fn fetch_keys(&self) -> Result<KeySet, Error> {
150        let response = self
151            .http
152            .get(self.endpoint("oauth/jwks")?)
153            .send()
154            .await
155            .map_err(|error| Error::Transport(Box::new(error)))?;
156        let text = response
157            .error_for_status()
158            .map_err(|error| Error::Transport(Box::new(error)))?
159            .text()
160            .await
161            .map_err(|error| Error::Transport(Box::new(error)))?;
162        KeySet::from_json(&text)
163    }
164
165    /// Exchange an authorization code using credentials owned by the caller.
166    pub async fn fetch_token(&self, request: &TokenRequest<'_>) -> Result<TokenResponse, Error> {
167        self.token(&code_form(request)?).await
168    }
169
170    /// [`Client::fetch_token`], asking Lys for the pass's grant binding
171    /// beside it (DIRECTORY-089 R2). An answer without one is refused
172    /// `grant_binding_required`, never taken as a pass at revision zero.
173    pub async fn fetch_bound_token(
174        &self,
175        request: &TokenRequest<'_>,
176    ) -> Result<TokenResponse, Error> {
177        let mut form = code_form(request)?.to_vec();
178        form.push(("grant_binding", binding::BINDING_REQUEST));
179        bound(self.token(&form).await?)
180    }
181
182    /// [`Client::refresh_token`], asking for the new pass's grant binding.
183    pub async fn refresh_bound_token(
184        &self,
185        client_id: &str,
186        client_secret: &str,
187        refresh_token: &str,
188    ) -> Result<TokenResponse, Error> {
189        if client_id.is_empty() || client_secret.is_empty() || refresh_token.is_empty() {
190            return Err(Error::Invalid("refresh request is incomplete"));
191        }
192        bound(
193            self.token(&[
194                ("grant_type", "refresh_token"),
195                ("client_id", client_id),
196                ("client_secret", client_secret),
197                ("refresh_token", refresh_token),
198                ("grant_binding", binding::BINDING_REQUEST),
199            ])
200            .await?,
201        )
202    }
203
204    /// Ask Lys to refresh from live grants; no timer or cached authority is used.
205    pub async fn refresh_token(
206        &self,
207        client_id: &str,
208        client_secret: &str,
209        refresh_token: &str,
210    ) -> Result<TokenResponse, Error> {
211        if client_id.is_empty() || client_secret.is_empty() || refresh_token.is_empty() {
212            return Err(Error::Invalid("refresh request is incomplete"));
213        }
214        self.token(&[
215            ("grant_type", "refresh_token"),
216            ("client_id", client_id),
217            ("client_secret", client_secret),
218            ("refresh_token", refresh_token),
219        ])
220        .await
221    }
222
223    async fn token(&self, form: &[(&str, &str)]) -> Result<TokenResponse, Error> {
224        let response = self
225            .http
226            .post(self.endpoint("oauth/token")?)
227            .form(form)
228            .send()
229            .await
230            .map_err(|error| Error::Transport(Box::new(error)))?;
231        read_token(response).await
232    }
233}
234
235/// The authorization-code exchange form of `request`.
236#[cfg(feature = "http")]
237fn code_form<'a>(request: &TokenRequest<'a>) -> Result<[(&'static str, &'a str); 6], Error> {
238    if request.client_id.is_empty()
239        || request.client_secret.is_empty()
240        || request.code.is_empty()
241        || request.redirect_uri.is_empty()
242        || request.code_verifier.is_empty()
243    {
244        return Err(Error::Invalid("token request is incomplete"));
245    }
246    Ok([
247        ("grant_type", "authorization_code"),
248        ("client_id", request.client_id),
249        ("client_secret", request.client_secret),
250        ("code", request.code),
251        ("redirect_uri", request.redirect_uri),
252        ("code_verifier", request.code_verifier),
253    ])
254}
255
256/// `answer`, refused by name when it carries no grant binding.
257#[cfg(feature = "http")]
258fn bound(answer: TokenResponse) -> Result<TokenResponse, Error> {
259    binding::required(answer.grant_binding.as_deref())?;
260    Ok(answer)
261}
262
263/// A token endpoint's answer: the pass, or the issuer's refusal by name. A
264/// refusal without a name is not a contract answer, and neither carries the
265/// response body into an error.
266#[cfg(feature = "http")]
267pub(crate) async fn read_token(response: reqwest::Response) -> Result<TokenResponse, Error> {
268    if !response.status().is_success() {
269        let refused: TokenRefusal = response
270            .json()
271            .await
272            .map_err(|error| Error::Transport(Box::new(error)))?;
273        if refused.error.is_empty()
274            || refused.refusal.is_empty()
275            || refused.reason != refused.error_description
276        {
277            return Err(Error::CannotAsk("token refusal has no name"));
278        }
279        return Err(Error::TokenRefused {
280            name: refused.refusal,
281            reason: refused.error_description,
282        });
283    }
284    let answer: TokenResponse = response
285        .json()
286        .await
287        .map_err(|error| Error::Transport(Box::new(error)))?;
288    if answer.token_type != "Bearer" || answer.access_token.is_empty() || answer.expires_in == 0 {
289        return Err(Error::CannotAsk("invalid token response"));
290    }
291    Ok(answer)
292}
293
294/// Borrowed authorization-code exchange inputs, never retained by the client.
295pub struct TokenRequest<'a> {
296    /// The registered audience.
297    pub client_id: &'a str,
298    /// The registered client credential.
299    pub client_secret: &'a str,
300    /// The one-use authorization code.
301    pub code: &'a str,
302    /// The registered return address.
303    pub redirect_uri: &'a str,
304    /// The authorization's PKCE verifier.
305    pub code_verifier: &'a str,
306}
307
308/// A token response owned by the product that requested it.
309#[derive(Deserialize, Serialize)]
310#[serde(deny_unknown_fields)]
311pub struct TokenResponse {
312    /// The signed access pass.
313    pub access_token: String,
314    /// The authorization scheme.
315    pub token_type: String,
316    /// The remaining lifetime in seconds.
317    pub expires_in: u64,
318    /// The identity assertion, when the exchange includes it.
319    pub id_token: Option<String>,
320    /// The credential used for a live refresh, when issued.
321    pub refresh_token: Option<String>,
322    /// The pass's signed grant binding, present only when it was asked for
323    /// (DIRECTORY-089 R2).
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub grant_binding: Option<String>,
326}
327
328#[cfg(feature = "http")]
329#[derive(Deserialize)]
330#[serde(deny_unknown_fields)]
331struct TokenRefusal {
332    error: String,
333    error_description: String,
334    refusal: String,
335    reason: String,
336}