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