Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 92 additions & 124 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ tracing = { version = "0.1.44", features = ["log-always"] }
url = "2.5.8"
uuid = { version = "1.23.3", features = ["v4"] }
whoami = "2.1.0"
oauth-device-flows = "0.1.0"
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "cookies"] }
tracing-subscriber = "0.3"
tempfile = "3"
1 change: 1 addition & 0 deletions ak-agent-desktop/src-tauri/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub async fn list_profiles(state: tauri::State<'_, Agent>) -> Result<Vec<Profile
authentik_url: c_prof.authentik_url.clone(),
last_renewed: Some(claims.iat.into()),
next_renew: Some(claims.exp.into()),
dpop_bound: c_prof.dpop_enabled(),
};
profiles.push(o_prof);
}
Expand Down
59 changes: 59 additions & 0 deletions ak-agent/src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::{collections::HashMap, fmt::Debug};

use ak_meta::user_agent;
use ak_platform::dpop::DpopKeyPair;
use ak_platform::log::LevelFilter;
use ak_platform::log::set_log_level;
use ak_platform::paths::DEFAULT_PROFILE;
Expand Down Expand Up @@ -41,12 +42,18 @@ pub struct ConfigV1Profile {
pub fallback_access_token: String,
#[serde(rename = "refresh_token")]
pub fallback_refresh_token: String,
// Empty string if this profile is not DPoP key-bound.
#[serde(rename = "dpop_private_key", default)]
pub fallback_dpop_private_key: String,

// Not saved to JSON, loaded from keychain
#[serde(skip)]
_access_token: String,
#[serde(skip)]
_refresh_token: String,
// PKCS#8 PEM; empty string if this profile is not DPoP key-bound.
#[serde(skip)]
_dpop_private_key: String,

#[serde(skip)]
_http_client: Option<Client>,
Expand All @@ -60,8 +67,13 @@ impl Debug for ConfigV1Profile {
.field("client_id", &self.client_id)
.field("fallback_access_token", &self.fallback_access_token.len())
.field("fallback_refresh_token", &self.fallback_refresh_token.len())
.field(
"fallback_dpop_private_key",
&self.fallback_dpop_private_key.len(),
)
.field("_access_token", &self._access_token.len())
.field("_refresh_token", &self._refresh_token.len())
.field("_dpop_private_key", &self._dpop_private_key.len())
.field("_http_client", &self._http_client)
.finish()
}
Expand All @@ -74,15 +86,18 @@ impl ConfigV1Profile {
client_id: String,
access_token: String,
refresh_token: String,
dpop_private_key: String,
) -> Self {
ConfigV1Profile {
authentik_url,
app_slug,
client_id,
fallback_access_token: "".to_string(),
fallback_refresh_token: "".to_string(),
fallback_dpop_private_key: "".to_string(),
_access_token: access_token,
_refresh_token: refresh_token,
_dpop_private_key: dpop_private_key,
_http_client: None,
}
}
Expand All @@ -103,6 +118,19 @@ impl ConfigV1Profile {
self._refresh_token = t.to_string()
}

/// Whether this profile has a DPoP keypair bound to it.
pub fn dpop_enabled(&self) -> bool {
!self._dpop_private_key.is_empty()
}

/// The profile's DPoP keypair, if it has one.
pub fn dpop_keypair(&self) -> Result<Option<DpopKeyPair>> {
if self._dpop_private_key.is_empty() {
return Ok(None);
}
Ok(Some(DpopKeyPair::from_pkcs8_pem(&self._dpop_private_key)?))
}

pub fn http_client(mut self) -> Client {
match self._http_client {
Some(c) => c,
Expand Down Expand Up @@ -186,6 +214,22 @@ impl Config for ConfigV1 {
}
Err(e) => return Err(e.into()),
}
tracing::debug!(profile = key, "Getting DPoP private key for profile");
match ak_platform_keyring::store()
.get(
&ak_platform_keyring::service("dpop_private_key"),
key,
ak_platform_keyring::Accessibility::User,
)
.await
{
Ok(v) => val._dpop_private_key = v,
Err(ak_platform_keyring::KeyringError::NotAvailable())
| Err(ak_platform_keyring::KeyringError::NotFound()) => {
val._dpop_private_key = val.fallback_dpop_private_key.clone()
}
Err(e) => return Err(e.into()),
}
}
Ok(())
}
Expand Down Expand Up @@ -222,6 +266,21 @@ impl Config for ConfigV1 {
}
Err(e) => return Err(e.into()),
};
match ak_platform_keyring::store()
.set(
&ak_platform_keyring::service("dpop_private_key"),
key,
ak_platform_keyring::Accessibility::User,
val._dpop_private_key.clone(),
)
.await
{
Ok(_) => {}
Err(ak_platform_keyring::KeyringError::NotAvailable()) => {
val.fallback_dpop_private_key = val._dpop_private_key.clone();
}
Err(e) => return Err(e.into()),
};
}
Ok(())
}
Expand Down
2 changes: 2 additions & 0 deletions ak-agent/src/grpc/agent_ctrl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ impl AgentCtrl for AgentGRPCServer {
authentik_url: c_prof.authentik_url.clone(),
last_renewed: Some(claims.iat.into()),
next_renew: Some(claims.exp.into()),
dpop_bound: c_prof.dpop_enabled(),
};
profiles.push(o_prof);
}
Expand Down Expand Up @@ -63,6 +64,7 @@ impl AgentCtrl for AgentGRPCServer {
req.client_id,
req.access_token,
req.refresh_token,
req.dpop_private_key,
),
);
if cfg.active_profile.is_empty() {
Expand Down
17 changes: 11 additions & 6 deletions ak-agent/src/token/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ impl ProfileTokenManager {
}

async fn renew(&self) -> Result<()> {
let (token_url, refresh_token, client_id) = {
let (token_url, refresh_token, client_id, dpop_keypair) = {
let config = self.cfg.read().await;
let profile = config
.profiles
Expand All @@ -202,6 +202,7 @@ impl ProfileTokenManager {
format!("{}/application/o/token/", profile.authentik_url),
profile.refresh_token().clone(),
profile.client_id.clone(),
profile.dpop_keypair()?,
)
};

Expand All @@ -210,17 +211,21 @@ impl ProfileTokenManager {
.append_pair("refresh_token", &refresh_token)
.finish();
let client = reqwest::Client::new();
let res = client
let mut req = client
.post(&token_url)
.basic_auth(&client_id, None::<&str>)
.header(
reqwest::header::CONTENT_TYPE,
"application/x-www-form-urlencoded",
)
.header(reqwest::header::USER_AGENT, user_agent())
.body(body)
.send()
.await?;
.header(reqwest::header::USER_AGENT, user_agent());

if let Some(kp) = &dpop_keypair {
let proof = ak_platform::dpop::build_proof(kp, "POST", &token_url, None)?;
req = req.header("DPoP", proof);
}

let res = req.body(body).send().await?;

if !res.status().is_success() {
let body = res.text().await?;
Expand Down
1 change: 0 additions & 1 deletion ak-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ chrono = { workspace = true }
clap = { version = "4.6.1", features = ["derive"] }
clap_complete = "4"
tracing = { workspace = true }
oauth-device-flows = { workspace = true }
open = "5.3.5"
pbjson-types = { workspace = true }
ratatui = "0.30.1"
Expand Down
68 changes: 38 additions & 30 deletions ak-cli/src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ pub enum ConfigCommands {
client_id: String,
#[arg(short = 'd', long, default_value = DEFAULT_APP_SLUG)]
app_slug: String,
/// Bind the resulting profile to a locally-generated key (RFC 9449 DPoP).
/// Requires an authentik server that supports OpenID Key Binding.
#[arg(long, default_value_t = false)]
dpop: bool,
},
}

Expand All @@ -51,6 +55,7 @@ pub async fn list_profiles(app: App) -> Result<()> {
println!("\tLast Renewal: {}", render_timestamp(profile.last_renewed));
println!("\tNext Renewal: {}", render_timestamp(profile.next_renew));
println!("\tauthentik URL: {}", profile.authentik_url);
println!("\tDPoP bound: {}", profile.dpop_bound);
}
Ok(())
}
Expand All @@ -60,9 +65,11 @@ pub async fn setup(
authentik_url: &str,
client_id: &str,
app_slug: &str,
dpop: bool,
) -> Result<()> {
let access_token: String;
let refresh_token: String;
let mut dpop_private_key = String::new();
if let Ok(at) = env::var("AK_CLI_ACCESS_TOKEN")
&& let Ok(rt) = env::var("AK_CLI_REFRESH_TOKEN")
{
Expand All @@ -74,6 +81,7 @@ pub async fn setup(
authentik_url: Url::parse(authentik_url).wrap_err("invalid authentik URL")?,
app_slug: app_slug.to_owned(),
client_id: client_id.to_owned(),
dpop_enabled: dpop,
url_callback: None,
})
.await
Expand All @@ -86,6 +94,9 @@ pub async fn setup(
} else {
bail!("Device-flow setup did not return access/refresh token");
}
if let Some(key) = prof.dpop_private_key_pem {
dpop_private_key = key;
}
}

let res = app
Expand All @@ -102,6 +113,7 @@ pub async fn setup(
client_id: client_id.to_owned(),
access_token: access_token.clone(),
refresh_token: refresh_token.clone(),
dpop_private_key,
})
.await
.wrap_err("failed to register profile with agent")?
Expand All @@ -111,34 +123,30 @@ pub async fn setup(
Ok(())
}

pub async fn current_profile(app: App) -> Result<()> {
let res = app
.user()
.await?
.clone()
.ctrl()
.current_profile(())
.await
.wrap_err("failed to get current profile")?
.into_inner();
assert_response_valid(res.header)?;
println!("{}", res.profile);
Ok(())
}

pub async fn switch_profile(app: App, profile: &str) -> Result<()> {
let res = app
.user()
.await?
.clone()
.ctrl()
.switch_profile(RequestHeader {
profile: profile.to_string(),
})
.await
.wrap_err("failed to switch profile")?
.into_inner();
assert_response_valid(Some(res))?;
println!("Successfully switched to profile '{profile}'!");
Ok(())
pub async fn switch_profile(app: App, profile: &Option<String>) -> Result<()> {
let mut ctrl = app.user().await?.clone().ctrl();
match profile {
Some(p) => {
let res = ctrl
.switch_profile(RequestHeader {
profile: p.to_string(),
})
.await
.wrap_err("failed to switch profile")?
.into_inner();
assert_response_valid(Some(res))?;
println!("Successfully switched to profile '{p}'!");
Ok(())
}
None => {
let res = ctrl
.current_profile(())
.await
.wrap_err("failed to get current profile")?
.into_inner();
assert_response_valid(res.header)?;
println!("{}", res.profile);
Ok(())
}
}
}
8 changes: 3 additions & 5 deletions ak-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,7 @@ enum Commands {
Version,
/// Switch to a different active profile
#[command(alias = "s")]
SwitchProfile {
#[arg(required = true)]
profile: String,
},
SwitchProfile { profile: Option<String> },

/// Configure authentik CLI
Config {
Expand Down Expand Up @@ -168,7 +165,8 @@ async fn main() -> std::result::Result<(), Error> {
authentik_url,
client_id,
app_slug,
} => commands::config::setup(app, authentik_url, client_id, app_slug).await,
dpop,
} => commands::config::setup(app, authentik_url, client_id, app_slug, *dpop).await,
},
Commands::Auth { command } => {
// If not in verbose, set a higher default log level as the output matters
Expand Down
Loading
Loading