-
-
Notifications
You must be signed in to change notification settings - Fork 27
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #379 from sebadob/client-device-flow-impl
feat: impl `device_code` flow into `rauthy-client`
- Loading branch information
Showing
14 changed files
with
405 additions
and
59 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
[package] | ||
name = "device-code-example" | ||
version = "0.1.0" | ||
edition = "2021" | ||
authors = ["Sebastian Dobe <sebastiandobe@mailbox.org>"] | ||
license = "Apache-2.0" | ||
|
||
[dependencies] | ||
anyhow = "1.0.75" | ||
axum = { version = "0.7.1", features = ["http2"] } | ||
axum-extra = { version = "0.9.0", features = ["cookie"] } | ||
dotenvy = "0.15.7" | ||
tokio = { version = "1.34.0", features = ["full"] } | ||
tracing = "0.1.40" | ||
tracing-subscriber = { version = "0.3.18", features = ["tracing"] } | ||
|
||
rauthy-client = { path = "../..", features = ["device-code"] } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# Device Authorization Grant Example | ||
|
||
TODO readme |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
use rauthy_client::device_code::DeviceCode; | ||
use rauthy_client::{DangerAcceptInvalidCerts, RauthyHttpsOnly}; | ||
use tracing::{subscriber, Level}; | ||
use tracing_subscriber::FmtSubscriber; | ||
|
||
#[tokio::main] | ||
async fn main() -> anyhow::Result<()> { | ||
let subscriber = FmtSubscriber::builder() | ||
.with_max_level(Level::INFO) | ||
.finish(); | ||
subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); | ||
|
||
let mut device_code = DeviceCode::request_with( | ||
"http://localhost:8080", | ||
"device".to_string(), | ||
Some("ZEW3dyKiPG27LdWrOFkNg6m9CaAEW5beoCxSufwdEBzSXMsxjNAXsnEbeI074d4V".to_string()), | ||
None, | ||
None, | ||
RauthyHttpsOnly::No, | ||
DangerAcceptInvalidCerts::Yes, | ||
) | ||
.await?; | ||
println!("{}", device_code); | ||
|
||
let _ts = device_code.wait_for_token().await?; | ||
|
||
// If the request has been verified, we have received a TokenSet at this point, | ||
// which we can use for furter requests. | ||
|
||
// TODO complete the example with qr code generation and fetch_userinfo() | ||
|
||
Ok(()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,259 @@ | ||
use crate::rauthy_error::RauthyError; | ||
use crate::token_set::OidcTokenSet; | ||
use crate::{DangerAcceptInvalidCerts, RauthyHttpsOnly, RootCertificate, VERSION}; | ||
use chrono::{DateTime, Utc}; | ||
use serde::{Deserialize, Serialize}; | ||
use std::borrow::Cow; | ||
use std::fmt::{Debug, Display, Formatter}; | ||
use std::ops::Add; | ||
use std::time::Duration; | ||
use tracing::{debug, info, warn}; | ||
|
||
#[derive(Debug, Deserialize)] | ||
struct DeviceCodeResponse { | ||
pub device_code: String, | ||
pub user_code: String, | ||
pub verification_uri: String, | ||
pub verification_uri_complete: Option<String>, | ||
pub expires_in: u16, | ||
pub interval: Option<u8>, | ||
} | ||
|
||
#[derive(Serialize)] | ||
struct DeviceGrantRequest<'a> { | ||
pub client_id: &'a str, | ||
pub client_secret: Option<&'a str>, | ||
pub scope: Option<&'a str>, | ||
} | ||
|
||
#[derive(Serialize)] | ||
struct DeviceGrantTokenRequest { | ||
pub client_id: String, | ||
pub client_secret: Option<String>, | ||
pub device_code: String, | ||
pub grant_type: &'static str, | ||
} | ||
|
||
#[derive(Debug, Deserialize)] | ||
struct MetaResponse { | ||
pub device_authorization_endpoint: String, | ||
pub token_endpoint: String, | ||
} | ||
|
||
#[derive(Debug, Deserialize)] | ||
pub struct OAuth2ErrorResponse<'a> { | ||
pub error: OAuth2ErrorTypeResponse, | ||
pub error_description: Option<Cow<'a, str>>, | ||
} | ||
|
||
#[derive(Debug, Deserialize)] | ||
#[serde(rename_all = "snake_case")] | ||
pub enum OAuth2ErrorTypeResponse { | ||
InvalidRequest, | ||
InvalidClient, | ||
InvalidGrant, | ||
UnauthorizedClient, | ||
UnsupportedGrantType, | ||
InvalidScope, | ||
// specific to the device grant | ||
AuthorizationPending, | ||
SlowDown, | ||
AccessDenied, | ||
ExpiredToken, | ||
} | ||
|
||
#[derive(Debug)] | ||
pub struct DeviceCode { | ||
pub issuer: String, | ||
client: reqwest::Client, | ||
token_endpoint: String, | ||
token_endpoint_payload: DeviceGrantTokenRequest, | ||
// token_endpoint_payload: String, | ||
interval: u64, | ||
|
||
pub expires: DateTime<Utc>, | ||
pub user_code: String, | ||
pub verification_uri: String, | ||
pub verification_uri_complete: Option<String>, | ||
} | ||
|
||
impl Debug for DeviceCode { | ||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { | ||
write!( | ||
f, | ||
"DeviceCode {{ token_endpoint: {}, token_endpoint_payload: <hidden>, expires: {}, \ | ||
user_code: {}, verification_uri: {}, verification_uri_complete: {:?} }}", | ||
self.token_endpoint, | ||
self.expires, | ||
self.user_code, | ||
self.verification_uri, | ||
self.verification_uri_complete, | ||
) | ||
} | ||
} | ||
|
||
impl Display for DeviceCode { | ||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { | ||
write!( | ||
f, | ||
r#" | ||
Please visit {} and enter your User Code: {} | ||
"#, | ||
self.verification_uri, self.user_code | ||
) | ||
} | ||
} | ||
|
||
impl DeviceCode { | ||
pub async fn request() -> Result<Self, RauthyError> { | ||
todo!() | ||
fn build_client( | ||
root_certificate: Option<RootCertificate>, | ||
https_only: RauthyHttpsOnly, | ||
danger_insecure: DangerAcceptInvalidCerts, | ||
) -> Result<reqwest::Client, RauthyError> { | ||
let builder = reqwest::Client::builder() | ||
.timeout(Duration::from_secs(10)) | ||
.connect_timeout(Duration::from_secs(10)) | ||
.https_only(https_only.bool()) | ||
.danger_accept_invalid_certs(danger_insecure.bool()) | ||
.user_agent(format!("Rauthy OIDC Client v{}", VERSION)) | ||
.timeout(Duration::from_secs(10)); | ||
if let Some(root) = root_certificate { | ||
Ok(builder.add_root_certificate(root).build()?) | ||
} else { | ||
Ok(builder.build()?) | ||
} | ||
} | ||
|
||
async fn fetch<T>(req: reqwest::RequestBuilder) -> Result<T, RauthyError> | ||
where | ||
T: Debug + for<'a> Deserialize<'a>, | ||
{ | ||
let res = req.send().await?; | ||
if !res.status().is_success() { | ||
let status = res.status().as_u16(); | ||
let err = match res.text().await { | ||
Ok(body) => { | ||
format!("Rauthy request error - HTTP {}: {:?}", status, body) | ||
} | ||
Err(_) => { | ||
format!("Rauthy request error - HTTP {}", status) | ||
} | ||
}; | ||
return Err(RauthyError::Request(Cow::from(err))); | ||
} | ||
Ok(res.json::<T>().await?) | ||
} | ||
|
||
//github.com/ Request a `device_code` from your Rauthy instance. | ||
//github.com/ This code can then be used in exchange for an OIDC Token Set. | ||
//github.com/ Clients requesting `device_code`'s are typically public. If you have a confidential | ||
//github.com/ client or need additional configuration, use `DeviceCode::request_with()`. | ||
pub async fn request(issuer: &str, client_id: String) -> Result<Self, RauthyError> { | ||
Self::request_with( | ||
issuer, | ||
client_id, | ||
None, | ||
None, | ||
None, | ||
RauthyHttpsOnly::Yes, | ||
DangerAcceptInvalidCerts::No, | ||
) | ||
.await | ||
} | ||
|
||
//github.com/ Request a `device_code` from your Rauthy instance. | ||
//github.com/ This code can then be used in exchange for an OIDC Token Set. | ||
pub async fn request_with( | ||
issuer: &str, | ||
client_id: String, | ||
client_secret: Option<String>, | ||
scope: Option<&str>, | ||
root_certificate: Option<RootCertificate>, | ||
https_only: RauthyHttpsOnly, | ||
danger_insecure: DangerAcceptInvalidCerts, | ||
) -> Result<Self, RauthyError> { | ||
let append = if issuer.ends_with('/') { | ||
".well-known/openid-configuration" | ||
} else { | ||
"/.well-known/openid-configuration" | ||
}; | ||
let oidc_config_url = format!("{}{}", issuer, append); | ||
|
||
let client = Self::build_client(root_certificate, https_only, danger_insecure)?; | ||
let meta = Self::fetch::<MetaResponse>(client.get(oidc_config_url)).await?; | ||
let device_code = Self::fetch::<DeviceCodeResponse>( | ||
client | ||
.post(meta.device_authorization_endpoint) | ||
.form(&DeviceGrantRequest { | ||
client_id: &client_id, | ||
client_secret: client_secret.as_deref(), | ||
scope, | ||
}), | ||
) | ||
.await?; | ||
let expires = Utc::now().add(chrono::Duration::seconds(device_code.expires_in as i64)); | ||
let token_endpoint_payload = DeviceGrantTokenRequest { | ||
client_id, | ||
client_secret, | ||
device_code: device_code.device_code, | ||
grant_type: "urn:ietf:params:oauth:grant-type:device_code", | ||
}; | ||
|
||
Ok(DeviceCode { | ||
client, | ||
token_endpoint: meta.token_endpoint, | ||
token_endpoint_payload, | ||
interval: device_code.interval.unwrap_or(5) as u64, | ||
expires, | ||
user_code: device_code.user_code, | ||
verification_uri: device_code.verification_uri, | ||
verification_uri_complete: device_code.verification_uri_complete, | ||
}) | ||
} | ||
|
||
//github.com/ With a valid `device_code`, continuously poll the Rauthy instance and wait | ||
//github.com/ for user verification of your request, to get an OIDC Token Set. | ||
pub async fn wait_for_token(&mut self) -> Result<OidcTokenSet, RauthyError> { | ||
let mut wait_for = self.interval; | ||
|
||
loop { | ||
tokio::time::sleep(Duration::from_secs(wait_for)).await; | ||
|
||
let res = self | ||
.client | ||
.post(&self.token_endpoint) | ||
.form(&self.token_endpoint_payload) | ||
.send() | ||
.await?; | ||
|
||
if res.status().is_success() { | ||
let ts = res.json::<OidcTokenSet>().await?; | ||
info!("Success - received an OIDC TokenSet"); | ||
return Ok(ts); | ||
} | ||
|
||
let err = res.json::<OAuth2ErrorResponse>().await?; | ||
match err.error { | ||
OAuth2ErrorTypeResponse::AuthorizationPending => { | ||
debug!("Authorization Pending - awaiting user verification"); | ||
} | ||
|
||
// this should not happen with Rauthy -> should always be just 5 seconds | ||
OAuth2ErrorTypeResponse::SlowDown => { | ||
warn!("Received a `slow_down` - doubling token fetch interval"); | ||
wait_for *= 2; | ||
} | ||
|
||
OAuth2ErrorTypeResponse::AccessDenied => { | ||
return Err(RauthyError::Provider(Cow::from(format!("{:?}", err)))); | ||
} | ||
|
||
OAuth2ErrorTypeResponse::ExpiredToken => { | ||
return Err(RauthyError::Provider(Cow::from(format!("{:?}", err)))); | ||
} | ||
|
||
// the others should not come up, only if the connection dies in between | ||
// or something like that | ||
_ => return Err(RauthyError::Provider(Cow::from(format!("{:?}", err)))), | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.