hydra_client/
lib.rs

1// Copyright 2020 Johan Fleury <jfleury@arcaik.net>
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//    http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::collections::HashMap;
19use thiserror::Error;
20use url::{self, Url};
21
22// Common Types
23
24#[derive(Debug, Error, Deserialize)]
25#[error("{description}")]
26pub struct ApiError {
27    pub error: String,
28
29    #[serde(rename = "error_description")]
30    pub description: String,
31
32    #[serde(rename = "error_debug")]
33    pub debug: String,
34
35    pub request_id: String,
36}
37
38#[derive(Debug, Deserialize)]
39pub struct CompletedRequest {
40    pub redirect_to: String,
41}
42
43#[derive(Debug, Deserialize)]
44pub struct OAuth2Client {
45    pub metadata: HashMap<String, String>,
46}
47
48// Login Types
49
50#[derive(Debug, Deserialize)]
51pub struct LoginRequest {
52    pub client: OAuth2Client,
53
54    #[serde(default)]
55    pub context: HashMap<String, Value>,
56
57    pub skip: bool,
58
59    pub subject: String,
60}
61
62#[derive(Debug, Serialize)]
63struct AcceptLoginRequest {
64    acr: Option<String>,
65    context: Option<HashMap<String, Value>>,
66    force_subject_identifier: Option<String>,
67    remember: Option<bool>,
68    remember_for: Option<u64>,
69    subject: String,
70}
71
72// Consent Types
73
74#[derive(Debug, Deserialize)]
75pub struct ConsentRequest {
76    #[serde(default)]
77    pub context: HashMap<String, Value>,
78
79    pub requested_access_token_audience: Vec<String>,
80
81    pub requested_scope: Vec<String>,
82
83    pub skip: bool,
84
85    pub subject: String,
86}
87
88#[derive(Debug, Serialize)]
89struct AcceptConsentRequest {
90    grant_access_token_audience: Vec<String>,
91    grant_scope: Vec<String>,
92    handled_at: DateTime<Utc>,
93    remember: Option<bool>,
94    remember_for: Option<u64>,
95    session: Option<ConsentRequestSession>,
96}
97
98#[derive(Debug, Serialize)]
99struct ConsentRequestSession {
100    id_token: Option<HashMap<String, Value>>,
101}
102
103// Logout Types
104
105#[derive(Debug, Serialize)]
106pub struct AcceptLogoutRequest;
107
108#[derive(Debug, Error)]
109pub enum Error {
110    #[error(transparent)]
111    RequestError(#[from] reqwest::Error),
112
113    #[error(transparent)]
114    URLParseError(#[from] url::ParseError),
115
116    #[error("API error: {}", .0.description)]
117    ApiError(#[from] ApiError),
118
119    #[error("Unknown error: {0}")]
120    UnknownError(String),
121}
122
123#[derive(Debug, Clone)]
124pub struct Hydra {
125    url: Url,
126    client: reqwest::blocking::Client,
127}
128
129impl Hydra {
130    pub fn new(url: Url) -> Hydra {
131        Hydra {
132            url,
133            client: reqwest::blocking::Client::new(),
134        }
135    }
136
137    // Login
138
139    pub fn get_login_request(&self, login_challenge: String) -> Result<LoginRequest, Error> {
140        self.get(
141            self.endpoint("/oauth2/auth/requests/login")?,
142            Some(format!("login_challenge={}", login_challenge).as_str()),
143        )
144    }
145
146    #[allow(clippy::too_many_arguments)]
147    pub fn accept_login_request(
148        &self,
149        login_challenge: String,
150        subject: String,
151        acr: Option<String>,
152        context: Option<HashMap<String, Value>>,
153        force_subject_identifier: Option<String>,
154        remember: Option<bool>,
155        remember_for: Option<u64>,
156    ) -> Result<CompletedRequest, Error> {
157        let body = AcceptLoginRequest {
158            acr,
159            context,
160            force_subject_identifier,
161            remember,
162            remember_for,
163            subject,
164        };
165
166        self.put(
167            self.endpoint("/oauth2/auth/requests/login/accept")?,
168            Some(format!("login_challenge={}", login_challenge).as_str()),
169            Some(body),
170        )
171    }
172
173    // Consent
174
175    pub fn get_consent_request(&self, consent_challenge: String) -> Result<ConsentRequest, Error> {
176        self.get(
177            self.endpoint("/oauth2/auth/requests/consent")?,
178            Some(format!("consent_challenge={}", consent_challenge).as_str()),
179        )
180    }
181
182    pub fn accept_consent_request(
183        &self,
184        consent_challenge: String,
185        grant_access_token_audience: Vec<String>,
186        grant_scope: Vec<String>,
187        remember: Option<bool>,
188        remember_for: Option<u64>,
189        claims: Option<HashMap<String, Value>>,
190    ) -> Result<CompletedRequest, Error> {
191        let session = match claims.is_some() {
192            true => Some(ConsentRequestSession { id_token: claims }),
193            false => None,
194        };
195
196        let body = AcceptConsentRequest {
197            grant_access_token_audience,
198            grant_scope,
199            handled_at: Utc::now(),
200            remember,
201            remember_for,
202            session,
203        };
204
205        self.put(
206            self.endpoint("/oauth2/auth/requests/consent/accept")?,
207            Some(format!("consent_challenge={}", consent_challenge).as_str()),
208            Some(body),
209        )
210    }
211
212    // Logout
213
214    pub fn accept_logout_request(
215        &self,
216        logout_challenge: String,
217    ) -> Result<CompletedRequest, Error> {
218        self.put(
219            self.endpoint("/oauth2/auth/requests/logout/accept")?,
220            Some(format!("logout_challenge={}", logout_challenge).as_str()),
221            AcceptLogoutRequest,
222        )
223    }
224
225    // Internal
226
227    fn endpoint(&self, endpoint: &str) -> Result<Url, Error> {
228        self.url
229            .clone()
230            .join(endpoint)
231            .map_err(Error::URLParseError)
232    }
233
234    fn deserialize<R: for<'de> Deserialize<'de>>(
235        r: reqwest::blocking::Response,
236    ) -> Result<R, Error> {
237        let status = r.status();
238
239        if status.is_success() {
240            r.json().map_err(Error::RequestError)
241        } else {
242            match r.json::<ApiError>() {
243                Ok(api_error) => Err(Error::ApiError(api_error)),
244                Err(_) => Err(Error::UnknownError(format!(
245                    "unable to parse reply from Hydra API (status: {})",
246                    status.clone()
247                ))),
248            }
249        }
250    }
251
252    fn get<T: for<'de> Deserialize<'de>>(&self, url: Url, query: Option<&str>) -> Result<T, Error> {
253        let mut url = url;
254        url.set_query(query);
255
256        let r = self.client.get(url).send()?;
257
258        Hydra::deserialize(r)
259    }
260
261    fn put<T: Serialize, R: for<'de> Deserialize<'de>>(
262        &self,
263        url: Url,
264        query: Option<&str>,
265        body: T,
266    ) -> Result<R, Error> {
267        let mut url = url;
268        url.set_query(query);
269
270        let r = self.client.put(url).json(&body).send()?;
271
272        Hydra::deserialize(r)
273    }
274}