From 73df196d72644ce2ccfd1811b9753ba55e5d1a3e Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 29 Jul 2026 20:14:52 +0100 Subject: [PATCH 1/2] init --- Cargo.lock | 216 +++++------- Cargo.toml | 1 - ak-agent-desktop/src-tauri/src/cmd.rs | 1 + ak-agent/src/config/mod.rs | 59 ++++ ak-agent/src/grpc/agent_ctrl.rs | 2 + ak-agent/src/token/profile.rs | 17 +- ak-cli/Cargo.toml | 1 - ak-cli/src/commands/config.rs | 12 + ak-cli/src/main.rs | 3 +- ak-cli/src/setup/mod.rs | 88 +++-- ak-platform-e2e/Cargo.toml | 1 - ak-platform-e2e/src/lib.rs | 74 ++-- ak-platform/Cargo.toml | 9 +- ak-platform/src/dpop.rs | 219 ++++++++++++ .../src/generated/agent_ctrl/agent_ctrl.rs | 5 + .../generated/agent_ctrl/agent_ctrl.serde.rs | 36 ++ ak-platform/src/lib.rs | 2 + ak-platform/src/oauth/device_flow.rs | 320 ++++++++++++++++++ ak-platform/src/oauth/mod.rs | 1 + protobuf/agent_ctrl.proto | 3 + 20 files changed, 860 insertions(+), 210 deletions(-) create mode 100644 ak-platform/src/dpop.rs create mode 100644 ak-platform/src/oauth/device_flow.rs create mode 100644 ak-platform/src/oauth/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 48d8ffb89..15bee74b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,7 +61,7 @@ dependencies = [ "eyre", "jsonwebtoken", "pbjson-types", - "reqwest 0.13.4", + "reqwest", "reqwest-middleware", "sentry", "sentry-tower", @@ -148,7 +148,6 @@ dependencies = [ "clap_complete", "color-eyre", "eyre", - "oauth-device-flows", "open", "pbjson-types", "ratatui", @@ -172,7 +171,7 @@ dependencies = [ "eyre", "httptest", "regex", - "reqwest 0.13.4", + "reqwest", "reqwest-middleware", "reqwest_cookie_store", "tokio", @@ -245,16 +244,20 @@ dependencies = [ "http-body-util", "hyper-util", "interprocess", + "jsonwebtoken", "libc", "log", "notify", "oslog", + "p256", "pbjson", "pbjson-types", "prost", + "reqwest", "serde", "serde_json", "serde_with", + "sha2 0.10.9", "simplelog", "ssh-agent-lib", "sysinfo", @@ -268,6 +271,9 @@ dependencies = [ "tower", "tower-http 0.7.0", "tracing", + "url", + "uuid", + "wiremock", ] [[package]] @@ -292,8 +298,7 @@ dependencies = [ "ak-platform", "authentik-client", "eyre", - "oauth-device-flows", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "testcontainers", @@ -359,7 +364,7 @@ dependencies = [ "libc", "pbjson-types", "rand 0.10.2", - "reqwest 0.13.4", + "reqwest", "reqwest-middleware", "rpassword", "rusqlite", @@ -549,6 +554,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "astral-tokio-tar" version = "0.6.4" @@ -762,7 +777,7 @@ version = "2026.8.0-rc1" source = "git+https://github.com/goauthentik/client-rust#33201238722a70fdbe8c3d34eb54a51e1053d3a8" dependencies = [ "chrono", - "reqwest 0.13.4", + "reqwest", "reqwest-middleware", "serde", "serde_json", @@ -1447,12 +1462,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "bytes" version = "1.12.1" @@ -2325,6 +2334,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "debugid" version = "0.8.0" @@ -2671,6 +2698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", + "base64ct", "crypto-bigint", "digest 0.10.7", "ff", @@ -2681,6 +2709,8 @@ dependencies = [ "pkcs8", "rand_core 0.6.4", "sec1", + "serde_json", + "serdect", "subtle", "zeroize", ] @@ -3854,7 +3884,6 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", - "webpki-roots", ] [[package]] @@ -4051,18 +4080,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", -] - [[package]] name = "indenter" version = "0.3.4" @@ -4722,16 +4739,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - [[package]] name = "muda" version = "0.19.3" @@ -4989,6 +4996,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -5020,25 +5037,6 @@ dependencies = [ "libc", ] -[[package]] -name = "oauth-device-flows" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20dd669fa331a18d6d83a31bbea02ec9d0ab494cfee6a474935106997986964" -dependencies = [ - "base64 0.22.1", - "qrcode", - "reqwest 0.12.28", - "secrecy 0.8.0", - "serde", - "serde_json", - "thiserror 1.0.69", - "time", - "tokio", - "url", - "uuid", -] - [[package]] name = "objc2" version = "0.6.4" @@ -6091,21 +6089,6 @@ dependencies = [ "psl-types", ] -[[package]] -name = "pxfm" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" - -[[package]] -name = "qrcode" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" -dependencies = [ - "image", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -6486,44 +6469,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "http 1.4.2", - "http-body 1.1.0", - "http-body-util", - "hyper 1.11.0", - "hyper-rustls 0.27.9", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls 0.23.42", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls 0.26.4", - "tower", - "tower-http 0.6.11", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - [[package]] name = "reqwest" version = "0.13.4" @@ -6579,7 +6524,7 @@ dependencies = [ "anyhow", "async-trait", "http 1.4.2", - "reqwest 0.13.4", + "reqwest", "serde", "thiserror 2.0.19", "tower-service", @@ -6593,7 +6538,7 @@ checksum = "beb98c4d52ae6ceed18a0dff0564717623dd75fae08619ae0927332f0a81abbc" dependencies = [ "bytes", "cookie_store", - "reqwest 0.13.4", + "reqwest", "url", ] @@ -6943,20 +6888,11 @@ dependencies = [ "der", "generic-array", "pkcs8", + "serdect", "subtle", "zeroize", ] -[[package]] -name = "secrecy" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" -dependencies = [ - "serde", - "zeroize", -] - [[package]] name = "secrecy" version = "0.10.3" @@ -7045,7 +6981,7 @@ checksum = "d631477761f57c76148456e55e80e9a479ff3fa4c65b2b4a0c3acf1167fd4638" dependencies = [ "cfg_aliases", "httpdate", - "reqwest 0.13.4", + "reqwest", "rustls 0.23.42", "sentry-backtrace", "sentry-contexts", @@ -7299,6 +7235,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -7602,7 +7548,7 @@ dependencies = [ "byteorder", "futures", "log", - "secrecy 0.10.3", + "secrecy", "service-binding", "signature", "ssh-encoding 0.2.0", @@ -7953,7 +7899,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "serde_repr", @@ -8461,7 +8407,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2 0.6.5", @@ -10112,6 +10057,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http 1.4.2", + "http-body-util", + "hyper 1.11.0", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index 5337d26c9..e387f5489 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/ak-agent-desktop/src-tauri/src/cmd.rs b/ak-agent-desktop/src-tauri/src/cmd.rs index b593f5e98..625e12f0d 100644 --- a/ak-agent-desktop/src-tauri/src/cmd.rs +++ b/ak-agent-desktop/src-tauri/src/cmd.rs @@ -33,6 +33,7 @@ pub async fn list_profiles(state: tauri::State<'_, Agent>) -> Result, @@ -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() } @@ -74,6 +86,7 @@ impl ConfigV1Profile { client_id: String, access_token: String, refresh_token: String, + dpop_private_key: String, ) -> Self { ConfigV1Profile { authentik_url, @@ -81,8 +94,10 @@ impl ConfigV1Profile { 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, } } @@ -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> { + 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, @@ -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(()) } @@ -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(()) } diff --git a/ak-agent/src/grpc/agent_ctrl.rs b/ak-agent/src/grpc/agent_ctrl.rs index 16bdc4289..4853beb19 100644 --- a/ak-agent/src/grpc/agent_ctrl.rs +++ b/ak-agent/src/grpc/agent_ctrl.rs @@ -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); } @@ -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() { diff --git a/ak-agent/src/token/profile.rs b/ak-agent/src/token/profile.rs index 8f8269584..fff488d6c 100644 --- a/ak-agent/src/token/profile.rs +++ b/ak-agent/src/token/profile.rs @@ -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 @@ -202,6 +202,7 @@ impl ProfileTokenManager { format!("{}/application/o/token/", profile.authentik_url), profile.refresh_token().clone(), profile.client_id.clone(), + profile.dpop_keypair()?, ) }; @@ -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?; diff --git a/ak-cli/Cargo.toml b/ak-cli/Cargo.toml index 95e538b89..b99657a4a 100644 --- a/ak-cli/Cargo.toml +++ b/ak-cli/Cargo.toml @@ -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" diff --git a/ak-cli/src/commands/config.rs b/ak-cli/src/commands/config.rs index 4ea699f5e..ba1fd20f7 100644 --- a/ak-cli/src/commands/config.rs +++ b/ak-cli/src/commands/config.rs @@ -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, }, } @@ -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(()) } @@ -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") { @@ -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 @@ -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 @@ -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")? diff --git a/ak-cli/src/main.rs b/ak-cli/src/main.rs index 5924eefc1..14b284def 100644 --- a/ak-cli/src/main.rs +++ b/ak-cli/src/main.rs @@ -168,7 +168,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 diff --git a/ak-cli/src/setup/mod.rs b/ak-cli/src/setup/mod.rs index e5f212d9d..8d388581a 100644 --- a/ak-cli/src/setup/mod.rs +++ b/ak-cli/src/setup/mod.rs @@ -1,11 +1,11 @@ use crate::format; use crate::setup::ak::urls_for_profile; +use ak_meta::user_agent; +use ak_platform::dpop::DpopKeyPair; +use ak_platform::oauth::device_flow::{poll_for_device_token, request_device_authorization}; use eyre::Result; -use oauth_device_flows::provider::GenericProviderConfig; -use oauth_device_flows::{DeviceFlow, DeviceFlowConfig, Provider}; use open::that; use ratatui::text::Line; -use std::time::Duration; use url::Url; pub mod ak; @@ -17,6 +17,7 @@ pub struct Options { pub authentik_url: Url, pub app_slug: String, pub client_id: String, + pub dpop_enabled: bool, pub url_callback: Option, } @@ -26,6 +27,8 @@ pub struct Profile { pub client_id: String, pub access_token: Option, pub refresh_token: Option, + /// PKCS#8 PEM DPoP private key, when `Options::dpop_enabled` was set. + pub dpop_private_key_pem: Option, } impl Profile { @@ -36,6 +39,7 @@ impl Profile { client_id, access_token: None, refresh_token: None, + dpop_private_key_pem: None, } } } @@ -66,45 +70,59 @@ pub async fn setup(opts: Options) -> Result { }, }; - let config = DeviceFlowConfig::new() - .client_id(opts.client_id.clone()) - .scopes(vec![ - "openid", - "profile", - "email", - "offline_access", - "goauthentik.io/api", - ]) - .poll_interval(Duration::from_secs(5)) - .generic_provider(GenericProviderConfig::new( - urls.device_code_url, - urls.token_url, - "authentik".to_owned(), - )) - .max_attempts(12); + let dpop_keypair = opts.dpop_enabled.then(DpopKeyPair::generate); + let dpop_jkt = dpop_keypair + .as_ref() + .map(DpopKeyPair::thumbprint) + .transpose()?; - let mut device_flow = DeviceFlow::new(Provider::Generic, config)?; + let mut scopes = vec![ + "openid", + "profile", + "email", + "offline_access", + "goauthentik.io/api", + ]; + if opts.dpop_enabled { + scopes.push("bound_key"); + } - let auth_response = device_flow.initialize().await?; + let auth = request_device_authorization( + &urls.device_code_url, + &opts.client_id, + &scopes, + dpop_jkt.as_deref(), + &user_agent(), + ) + .await?; - let verification_uri = match auth_response.verification_uri_complete() { - Some(vu) => vu, - None => auth_response.verification_uri(), - }; - callback(verification_uri.clone())?; + let verification_uri = auth + .verification_uri_complete + .clone() + .unwrap_or_else(|| auth.verification_uri.clone()); + callback(verification_uri)?; eprintln!("Waiting for authentication..."); - let token_response = device_flow.poll_for_token().await?; + let token_response = poll_for_device_token( + &urls.token_url, + &opts.client_id, + &auth, + dpop_keypair.as_ref(), + &user_agent(), + ) + .await?; + + let dpop_private_key_pem = dpop_keypair + .as_ref() + .map(DpopKeyPair::to_pkcs8_pem) + .transpose()?; - let mut profile = Profile { + Ok(Profile { authentik_url: opts.authentik_url.clone(), app_slug: opts.app_slug.clone(), client_id: opts.client_id.clone(), - access_token: Some(token_response.access_token().to_owned()), - refresh_token: None, - }; - if let Some(token) = token_response.refresh_token() { - profile.refresh_token = Some(token.to_owned()) - } - Ok(profile) + access_token: Some(token_response.access_token), + refresh_token: token_response.refresh_token, + dpop_private_key_pem, + }) } diff --git a/ak-platform-e2e/Cargo.toml b/ak-platform-e2e/Cargo.toml index 33506d619..592087c91 100644 --- a/ak-platform-e2e/Cargo.toml +++ b/ak-platform-e2e/Cargo.toml @@ -14,7 +14,6 @@ ak-flow-executor = { path = "../ak-flow-executor" } ak-platform = { path = "../ak-platform" } authentik-client.workspace = true eyre.workspace = true -oauth-device-flows = { workspace = true } reqwest.workspace = true serde_json.workspace = true serde.workspace = true diff --git a/ak-platform-e2e/src/lib.rs b/ak-platform-e2e/src/lib.rs index efbb47f56..1764ae041 100644 --- a/ak-platform-e2e/src/lib.rs +++ b/ak-platform-e2e/src/lib.rs @@ -2,11 +2,10 @@ use std::{env, path::PathBuf, time::Duration}; use ak_flow_executor::executor::FlowExecutor; use ak_platform::log::{LevelFilter, LogBuilder}; +use ak_platform::oauth::device_flow::{poll_for_device_token, request_device_authorization}; use ak_platform::string::PlatformString; use authentik_client::apis::{configuration::Configuration as AkConfig, endpoints_api}; use eyre::{Context, ContextCompat, Result, bail}; -use oauth_device_flows::provider::GenericProviderConfig; -use oauth_device_flows::{DeviceFlow, DeviceFlowConfig, Provider}; use testcontainers::core::CmdWaitFor; use testcontainers::{ContainerAsync, GenericImage, core::ExecCommand}; use url::Url; @@ -104,38 +103,33 @@ pub async fn agent_setup(tm: &TestMachine) -> Result<()> { base.set_path(&format!("{}/", base.path())); } - let config = DeviceFlowConfig::new() - .client_id("authentik-cli") - .scopes(vec![ - "openid", - "profile", - "email", - "offline_access", - "goauthentik.io/api", - ]) - .poll_interval(Duration::from_secs(5)) - .generic_provider(GenericProviderConfig::new( - base.join("application/o/device/") - .wrap_err("invalid device URL")?, - base.join("application/o/token/") - .wrap_err("invalid token URL")?, - "authentik".to_owned(), - )) - .max_attempts(12); - - let mut device_flow = - DeviceFlow::new(Provider::Generic, config).wrap_err("failed to create device flow")?; - - let verification_uri = { - let auth_response = device_flow - .initialize() - .await - .wrap_err("device flow initialization failed")?; - auth_response - .verification_uri_complete() - .unwrap_or_else(|| auth_response.verification_uri()) - .clone() - }; + let device_code_url = base + .join("application/o/device/") + .wrap_err("invalid device URL")?; + let token_url = base + .join("application/o/token/") + .wrap_err("invalid token URL")?; + let scopes = [ + "openid", + "profile", + "email", + "offline_access", + "goauthentik.io/api", + ]; + + let auth = request_device_authorization( + &device_code_url, + "authentik-cli", + &scopes, + None, + "ak-platform-e2e", + ) + .await + .wrap_err("device flow initialization failed")?; + let verification_uri = auth + .verification_uri_complete + .clone() + .unwrap_or_else(|| auth.verification_uri.clone()); // Auto-approve: visit the verification URI with an authenticated session, // then submit the implicit consent form. @@ -161,14 +155,14 @@ pub async fn agent_setup(tm: &TestMachine) -> Result<()> { .await; } - let token_response = device_flow - .poll_for_token() - .await - .wrap_err("device flow polling failed")?; + let token_response = + poll_for_device_token(&token_url, "authentik-cli", &auth, None, "ak-platform-e2e") + .await + .wrap_err("device flow polling failed")?; let ak_url = container_authentik_url(); - let access_token = token_response.access_token().to_owned(); - let refresh_token = token_response.refresh_token().unwrap_or("").to_owned(); + let access_token = token_response.access_token; + let refresh_token = token_response.refresh_token.unwrap_or_default(); must_exec( &tm.container, &format!("ak config setup -a {}", ak_url), diff --git a/ak-platform/Cargo.toml b/ak-platform/Cargo.toml index c774ddd0c..eca0578fd 100644 --- a/ak-platform/Cargo.toml +++ b/ak-platform/Cargo.toml @@ -12,6 +12,12 @@ name = "grpc_client" [dependencies] base64 = "0.23.0" +jsonwebtoken = { version = "11", features = ["rust_crypto"] } +p256 = { version = "0.13", features = ["pkcs8", "jwk"] } +sha2 = "0.10" +uuid = { workspace = true } +reqwest = { workspace = true } +url = { workspace = true } hyper-util = { workspace = true } log = { workspace = true } pbjson = { workspace = true } @@ -53,5 +59,6 @@ libc = "0.2.186" eventlog = "0.4.0" [dev-dependencies] -tokio = { workspace = true, features = ["macros", "io-util"] } +tokio = { workspace = true, features = ["macros", "io-util", "time"] } tempfile = { workspace = true } +wiremock = "0.6" diff --git a/ak-platform/src/dpop.rs b/ak-platform/src/dpop.rs new file mode 100644 index 000000000..e3fc0854a --- /dev/null +++ b/ak-platform/src/dpop.rs @@ -0,0 +1,219 @@ +//! RFC 9449 DPoP (Demonstrating Proof-of-Possession) proof generation, as +//! required by authentik's OpenID Key Binding feature. + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::Utc; +use eyre::{Result, eyre}; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, jwk::Jwk}; +use p256::SecretKey; +use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +/// JOSE `typ` header value for DPoP proof JWTs (RFC 9449 section 4.2). +pub const DPOP_JWT_TYP: &str = "dpop+jwt"; + +/// An EC (P-256) keypair used to prove possession of a DPoP-bound token. +/// +/// The same keypair must be reused across the authorization, token and +/// subsequent refresh requests of a single key-bound session: the server +/// recomputes the JWK thumbprint from each proof and compares it against the +/// one recorded at authorization time. +pub struct DpopKeyPair(SecretKey); + +impl DpopKeyPair { + /// Generate a new random P-256 keypair. + pub fn generate() -> Self { + Self(SecretKey::random( + &mut p256::elliptic_curve::rand_core::OsRng, + )) + } + + /// Serialize the private key as a PKCS#8 PEM string, for storage. + pub fn to_pkcs8_pem(&self) -> Result { + Ok(self.0.to_pkcs8_pem(LineEnding::LF)?.to_string()) + } + + /// Parse a keypair previously serialized with [`Self::to_pkcs8_pem`]. + pub fn from_pkcs8_pem(pem: &str) -> Result { + Ok(Self(SecretKey::from_pkcs8_pem(pem)?)) + } + + /// The public key as a JOSE JWK, suitable for the DPoP proof header. + pub fn public_jwk(&self) -> Result { + let jwk_ec = self.0.public_key().to_jwk(); + let value = serde_json::to_value(&jwk_ec)?; + Ok(serde_json::from_value(value)?) + } + + /// RFC 7638 JWK thumbprint (`dpop_jkt`): base64url(SHA-256(canonical JWK)). + pub fn thumbprint(&self) -> Result { + let jwk_ec = self.0.public_key().to_jwk(); + let value = serde_json::to_value(&jwk_ec)?; + let x = value + .get("x") + .and_then(|v| v.as_str()) + .ok_or_else(|| eyre!("EC JWK missing x coordinate"))?; + let y = value + .get("y") + .and_then(|v| v.as_str()) + .ok_or_else(|| eyre!("EC JWK missing y coordinate"))?; + // RFC 7638 requires the exact required member set for an EC key, + // in lexicographic order, with no insignificant whitespace. Built by + // hand rather than via a serde_json::Map, since key ordering there is + // an implementation detail this computation must not depend on. + let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x}","y":"{y}"}}"#); + Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()))) + } + + /// A signing key usable with `jsonwebtoken::encode`. + fn encoding_key(&self) -> Result { + let pem = self.to_pkcs8_pem()?; + Ok(EncodingKey::from_ec_pem(pem.as_bytes())?) + } +} + +#[derive(Serialize)] +struct DpopClaims<'a> { + htm: &'a str, + htu: &'a str, + iat: i64, + jti: String, + #[serde(skip_serializing_if = "Option::is_none")] + c_s256: Option, +} + +/// Build a signed DPoP proof JWT (RFC 9449 section 4.2) for a single request. +/// +/// `code_for_c_s256` must be `Some(raw authorization code or device code)` when +/// proving possession during the authorization_code/device_code token +/// exchange, and `None` for a refresh_token grant, which carries no `c_s256` +/// claim. `iat` and `jti` are generated fresh on every call; callers cannot +/// (and must not be able to) reuse them, since that would defeat the replay +/// protection the claims exist for. +pub fn build_proof( + key: &DpopKeyPair, + htm: &str, + htu: &str, + code_for_c_s256: Option<&str>, +) -> Result { + let mut header = Header::new(Algorithm::ES256); + header.typ = Some(DPOP_JWT_TYP.to_string()); + header.jwk = Some(key.public_jwk()?); + + let claims = DpopClaims { + htm, + htu, + iat: Utc::now().timestamp(), + jti: Uuid::new_v4().to_string(), + c_s256: code_for_c_s256.map(|code| URL_SAFE_NO_PAD.encode(Sha256::digest(code.as_bytes()))), + }; + + Ok(encode(&header, &claims, &key.encoding_key()?)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thumbprint_is_well_formed() -> Result<()> { + let key = DpopKeyPair::generate(); + let jkt = key.thumbprint()?; + assert_eq!(jkt.len(), 43); + assert!( + jkt.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + ); + Ok(()) + } + + #[test] + fn pkcs8_pem_round_trips() -> Result<()> { + let key = DpopKeyPair::generate(); + let pem = key.to_pkcs8_pem()?; + let key2 = DpopKeyPair::from_pkcs8_pem(&pem)?; + assert_eq!(key.thumbprint()?, key2.thumbprint()?); + Ok(()) + } + + #[test] + fn different_keys_have_different_thumbprints() -> Result<()> { + let a = DpopKeyPair::generate(); + let b = DpopKeyPair::generate(); + assert_ne!(a.thumbprint()?, b.thumbprint()?); + Ok(()) + } + + #[test] + fn public_jwk_has_no_private_material() -> Result<()> { + let key = DpopKeyPair::generate(); + let jwk = key.public_jwk()?; + let value = serde_json::to_value(&jwk)?; + assert!(value.get("d").is_none()); + Ok(()) + } + + #[test] + fn build_proof_has_expected_shape() -> Result<()> { + let key = DpopKeyPair::generate(); + let jwt = build_proof( + &key, + "POST", + "https://example.com/application/o/token/", + None, + )?; + let parts: Vec<&str> = jwt.split('.').collect(); + assert_eq!(parts.len(), 3); + + let header: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0])?)?; + assert_eq!(header["typ"], DPOP_JWT_TYP); + assert_eq!(header["alg"], "ES256"); + assert!(header["jwk"]["x"].is_string()); + assert!(header["jwk"]["d"].is_null()); + + let payload: serde_json::Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1])?)?; + assert_eq!(payload["htm"], "POST"); + assert_eq!(payload["htu"], "https://example.com/application/o/token/"); + assert!(payload["c_s256"].is_null()); + assert!(payload["jti"].is_string()); + Ok(()) + } + + #[test] + fn build_proof_includes_c_s256_when_code_given() -> Result<()> { + let key = DpopKeyPair::generate(); + let jwt = build_proof( + &key, + "POST", + "https://example.com/application/o/token/", + Some("abc123"), + )?; + let parts: Vec<&str> = jwt.split('.').collect(); + let payload: serde_json::Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1])?)?; + assert!(payload["c_s256"].is_string()); + Ok(()) + } + + #[test] + fn build_proof_generates_fresh_jti_each_time() -> Result<()> { + let key = DpopKeyPair::generate(); + let jwt1 = build_proof( + &key, + "POST", + "https://example.com/application/o/token/", + None, + )?; + let jwt2 = build_proof( + &key, + "POST", + "https://example.com/application/o/token/", + None, + )?; + assert_ne!(jwt1, jwt2); + Ok(()) + } +} diff --git a/ak-platform/src/generated/agent_ctrl/agent_ctrl.rs b/ak-platform/src/generated/agent_ctrl/agent_ctrl.rs index 2af26aad5..142d32833 100644 --- a/ak-platform/src/generated/agent_ctrl/agent_ctrl.rs +++ b/ak-platform/src/generated/agent_ctrl/agent_ctrl.rs @@ -14,6 +14,9 @@ pub struct SetupRequest { pub access_token: ::prost::alloc::string::String, #[prost(string, tag="6")] pub refresh_token: ::prost::alloc::string::String, + /// PKCS#8 PEM DPoP private key; empty if this profile is not key-bound. + #[prost(string, tag="7")] + pub dpop_private_key: ::prost::alloc::string::String, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct SetupResponse { @@ -32,6 +35,8 @@ pub struct Profile { pub last_renewed: ::core::option::Option<::pbjson_types::Timestamp>, #[prost(message, optional, tag="5")] pub next_renew: ::core::option::Option<::pbjson_types::Timestamp>, + #[prost(bool, tag="6")] + pub dpop_bound: bool, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct ListProfilesResponse { diff --git a/ak-platform/src/generated/agent_ctrl/agent_ctrl.serde.rs b/ak-platform/src/generated/agent_ctrl/agent_ctrl.serde.rs index 2da5d41fd..7f6d8e7f5 100644 --- a/ak-platform/src/generated/agent_ctrl/agent_ctrl.serde.rs +++ b/ak-platform/src/generated/agent_ctrl/agent_ctrl.serde.rs @@ -238,6 +238,9 @@ impl serde::Serialize for Profile { if self.next_renew.is_some() { len += 1; } + if self.dpop_bound { + len += 1; + } let mut struct_ser = serializer.serialize_struct("agent_ctrl.Profile", len)?; if !self.name.is_empty() { struct_ser.serialize_field("name", &self.name)?; @@ -254,6 +257,9 @@ impl serde::Serialize for Profile { if let Some(v) = self.next_renew.as_ref() { struct_ser.serialize_field("nextRenew", v)?; } + if self.dpop_bound { + struct_ser.serialize_field("dpopBound", &self.dpop_bound)?; + } struct_ser.end() } } @@ -272,6 +278,8 @@ impl<'de> serde::Deserialize<'de> for Profile { "lastRenewed", "next_renew", "nextRenew", + "dpop_bound", + "dpopBound", ]; #[allow(clippy::enum_variant_names)] @@ -281,6 +289,7 @@ impl<'de> serde::Deserialize<'de> for Profile { AuthentikUrl, LastRenewed, NextRenew, + DpopBound, } impl<'de> serde::Deserialize<'de> for GeneratedField { fn deserialize(deserializer: D) -> std::result::Result @@ -307,6 +316,7 @@ impl<'de> serde::Deserialize<'de> for Profile { "authentikUrl" | "authentik_url" => Ok(GeneratedField::AuthentikUrl), "lastRenewed" | "last_renewed" => Ok(GeneratedField::LastRenewed), "nextRenew" | "next_renew" => Ok(GeneratedField::NextRenew), + "dpopBound" | "dpop_bound" => Ok(GeneratedField::DpopBound), _ => Err(serde::de::Error::unknown_field(value, FIELDS)), } } @@ -331,6 +341,7 @@ impl<'de> serde::Deserialize<'de> for Profile { let mut authentik_url__ = None; let mut last_renewed__ = None; let mut next_renew__ = None; + let mut dpop_bound__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::Name => { @@ -363,6 +374,12 @@ impl<'de> serde::Deserialize<'de> for Profile { } next_renew__ = map_.next_value()?; } + GeneratedField::DpopBound => { + if dpop_bound__.is_some() { + return Err(serde::de::Error::duplicate_field("dpopBound")); + } + dpop_bound__ = Some(map_.next_value()?); + } } } Ok(Profile { @@ -371,6 +388,7 @@ impl<'de> serde::Deserialize<'de> for Profile { authentik_url: authentik_url__.unwrap_or_default(), last_renewed: last_renewed__, next_renew: next_renew__, + dpop_bound: dpop_bound__.unwrap_or_default(), }) } } @@ -403,6 +421,9 @@ impl serde::Serialize for SetupRequest { if !self.refresh_token.is_empty() { len += 1; } + if !self.dpop_private_key.is_empty() { + len += 1; + } let mut struct_ser = serializer.serialize_struct("agent_ctrl.SetupRequest", len)?; if let Some(v) = self.header.as_ref() { struct_ser.serialize_field("header", v)?; @@ -422,6 +443,9 @@ impl serde::Serialize for SetupRequest { if !self.refresh_token.is_empty() { struct_ser.serialize_field("refreshToken", &self.refresh_token)?; } + if !self.dpop_private_key.is_empty() { + struct_ser.serialize_field("dpopPrivateKey", &self.dpop_private_key)?; + } struct_ser.end() } } @@ -443,6 +467,8 @@ impl<'de> serde::Deserialize<'de> for SetupRequest { "accessToken", "refresh_token", "refreshToken", + "dpop_private_key", + "dpopPrivateKey", ]; #[allow(clippy::enum_variant_names)] @@ -453,6 +479,7 @@ impl<'de> serde::Deserialize<'de> for SetupRequest { ClientId, AccessToken, RefreshToken, + DpopPrivateKey, } impl<'de> serde::Deserialize<'de> for GeneratedField { fn deserialize(deserializer: D) -> std::result::Result @@ -480,6 +507,7 @@ impl<'de> serde::Deserialize<'de> for SetupRequest { "clientId" | "client_id" => Ok(GeneratedField::ClientId), "accessToken" | "access_token" => Ok(GeneratedField::AccessToken), "refreshToken" | "refresh_token" => Ok(GeneratedField::RefreshToken), + "dpopPrivateKey" | "dpop_private_key" => Ok(GeneratedField::DpopPrivateKey), _ => Err(serde::de::Error::unknown_field(value, FIELDS)), } } @@ -505,6 +533,7 @@ impl<'de> serde::Deserialize<'de> for SetupRequest { let mut client_id__ = None; let mut access_token__ = None; let mut refresh_token__ = None; + let mut dpop_private_key__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::Header => { @@ -543,6 +572,12 @@ impl<'de> serde::Deserialize<'de> for SetupRequest { } refresh_token__ = Some(map_.next_value()?); } + GeneratedField::DpopPrivateKey => { + if dpop_private_key__.is_some() { + return Err(serde::de::Error::duplicate_field("dpopPrivateKey")); + } + dpop_private_key__ = Some(map_.next_value()?); + } } } Ok(SetupRequest { @@ -552,6 +587,7 @@ impl<'de> serde::Deserialize<'de> for SetupRequest { client_id: client_id__.unwrap_or_default(), access_token: access_token__.unwrap_or_default(), refresh_token: refresh_token__.unwrap_or_default(), + dpop_private_key: dpop_private_key__.unwrap_or_default(), }) } } diff --git a/ak-platform/src/lib.rs b/ak-platform/src/lib.rs index 2cbd0795c..6f29565db 100644 --- a/ak-platform/src/lib.rs +++ b/ak-platform/src/lib.rs @@ -1,9 +1,11 @@ pub mod client; pub mod config; +pub mod dpop; pub mod generated; pub mod grpc; pub mod log; pub mod net; +pub mod oauth; pub mod paths; pub mod shared; pub mod storage; diff --git a/ak-platform/src/oauth/device_flow.rs b/ak-platform/src/oauth/device_flow.rs new file mode 100644 index 000000000..fe6b08405 --- /dev/null +++ b/ak-platform/src/oauth/device_flow.rs @@ -0,0 +1,320 @@ +//! A minimal RFC 8628 OAuth 2.0 Device Authorization Grant client. +//! +//! Hand-rolled rather than pulled from a third-party crate because DPoP +//! (RFC 9449) needs to inject a `dpop_jkt` form parameter into the initial +//! device-authorization request and a freshly-signed `DPoP` header into every +//! token poll — neither of which a generic device-flow crate exposes a hook +//! for. + +use std::time::{Duration, Instant}; + +use eyre::{Result, bail, eyre}; +use serde::Deserialize; +use url::Url; + +use crate::dpop::{DpopKeyPair, build_proof}; + +const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(5); +const SLOW_DOWN_INCREMENT: Duration = Duration::from_secs(5); + +/// The result of a successful device authorization request (RFC 8628 section 3.2). +pub struct DeviceAuthorization { + /// Never shown to the user; used to poll the token endpoint and, when + /// DPoP is enabled, as the input to the proof's `c_s256` claim. + pub device_code: String, + pub user_code: String, + pub verification_uri: Url, + pub verification_uri_complete: Option, + pub interval: Duration, + pub expires_at: Instant, +} + +#[derive(Deserialize)] +struct DeviceAuthorizationResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: Option, + expires_in: u64, + #[serde(default)] + interval: Option, +} + +/// Start a device authorization grant (RFC 8628 section 3.1/3.2). +/// +/// `dpop_jkt`, when set, is sent as the `dpop_jkt` form parameter and the +/// caller is expected to have also added the `bound_key` scope to `scopes`. +pub async fn request_device_authorization( + device_code_url: &Url, + client_id: &str, + scopes: &[&str], + dpop_jkt: Option<&str>, + user_agent: &str, +) -> Result { + let mut form = url::form_urlencoded::Serializer::new(String::new()); + form.append_pair("client_id", client_id); + form.append_pair("scope", &scopes.join(" ")); + if let Some(jkt) = dpop_jkt { + form.append_pair("dpop_jkt", jkt); + } + let body = form.finish(); + + let res = reqwest::Client::new() + .post(device_code_url.clone()) + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .header(reqwest::header::USER_AGENT, user_agent) + .body(body) + .send() + .await?; + + if !res.status().is_success() { + let body = res.text().await.unwrap_or_default(); + bail!("device authorization request failed: {body}"); + } + + let parsed: DeviceAuthorizationResponse = res.json().await?; + Ok(DeviceAuthorization { + device_code: parsed.device_code, + user_code: parsed.user_code, + verification_uri: Url::parse(&parsed.verification_uri)?, + verification_uri_complete: parsed + .verification_uri_complete + .map(|u| Url::parse(&u)) + .transpose()?, + interval: parsed + .interval + .map(Duration::from_secs) + .unwrap_or(DEFAULT_POLL_INTERVAL), + expires_at: Instant::now() + Duration::from_secs(parsed.expires_in), + }) +} + +/// The token response of a completed device authorization grant. +pub struct DeviceTokenResult { + pub access_token: String, + pub refresh_token: Option, + pub expires_in: Option, +} + +#[derive(Deserialize)] +struct TokenSuccessResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, +} + +#[derive(Deserialize)] +struct TokenErrorResponse { + error: String, +} + +/// Poll the token endpoint until the user completes authorization (RFC 8628 +/// section 3.4/3.5), or bail out on denial/expiry. +/// +/// `dpop_keypair`, when set, attaches a fresh `DPoP` proof header (with +/// `c_s256` bound to `auth.device_code`) to every poll attempt. +pub async fn poll_for_device_token( + token_url: &Url, + client_id: &str, + auth: &DeviceAuthorization, + dpop_keypair: Option<&DpopKeyPair>, + user_agent: &str, +) -> Result { + let mut interval = auth.interval; + let client = reqwest::Client::new(); + + loop { + if Instant::now() >= auth.expires_at { + bail!("device code expired before authorization was completed"); + } + tokio::time::sleep(interval).await; + + let body = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + .append_pair("device_code", &auth.device_code) + .append_pair("client_id", client_id) + .finish(); + + let mut req = client + .post(token_url.clone()) + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .header(reqwest::header::USER_AGENT, user_agent); + + if let Some(kp) = dpop_keypair { + let proof = build_proof(kp, "POST", token_url.as_str(), Some(&auth.device_code))?; + req = req.header("DPoP", proof); + } + + let res = req.body(body).send().await?; + let status = res.status(); + let text = res.text().await?; + + if status.is_success() { + let parsed: TokenSuccessResponse = serde_json::from_str(&text) + .map_err(|e| eyre!("malformed token response: {e}: {text}"))?; + return Ok(DeviceTokenResult { + access_token: parsed.access_token, + refresh_token: parsed.refresh_token, + expires_in: parsed.expires_in, + }); + } + + let error: TokenErrorResponse = serde_json::from_str(&text) + .map_err(|_| eyre!("device token poll failed with status {status}: {text}"))?; + + match error.error.as_str() { + "authorization_pending" => continue, + "slow_down" => { + interval += SLOW_DOWN_INCREMENT; + continue; + } + "access_denied" => bail!("authorization was denied"), + "expired_token" => bail!("device code expired before authorization was completed"), + other => bail!("device token poll failed: {other}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn test_auth(interval: Duration) -> Result { + Ok(DeviceAuthorization { + device_code: "test-device-code".to_string(), + user_code: "ABCD-EFGH".to_string(), + verification_uri: Url::parse("https://example.com/device")?, + verification_uri_complete: None, + interval, + expires_at: Instant::now() + Duration::from_secs(600), + }) + } + + #[tokio::test] + async fn request_device_authorization_parses_response() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/device/")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "device_code": "devcode123", + "user_code": "WXYZ-1234", + "verification_uri": "https://example.com/verify", + "verification_uri_complete": "https://example.com/verify?code=WXYZ-1234", + "expires_in": 600, + "interval": 5 + }))) + .mount(&server) + .await; + + let url = Url::parse(&format!("{}/device/", server.uri()))?; + let auth = request_device_authorization( + &url, + "client-id", + &["openid"], + Some("thumbprint"), + "test-agent", + ) + .await?; + + assert_eq!(auth.device_code, "devcode123"); + assert_eq!(auth.user_code, "WXYZ-1234"); + assert_eq!(auth.interval, Duration::from_secs(5)); + assert!(auth.verification_uri_complete.is_some()); + Ok(()) + } + + #[tokio::test] + async fn poll_retries_on_authorization_pending_then_succeeds() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token/")) + .respond_with( + ResponseTemplate::new(400).set_body_json(json!({"error": "authorization_pending"})), + ) + .up_to_n_times(2) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token/")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "at123", + "refresh_token": "rt123", + "expires_in": 3600 + }))) + .mount(&server) + .await; + + let url = Url::parse(&format!("{}/token/", server.uri()))?; + let auth = test_auth(Duration::from_millis(10))?; + let result = poll_for_device_token(&url, "client-id", &auth, None, "test-agent").await?; + + assert_eq!(result.access_token, "at123"); + assert_eq!(result.refresh_token.as_deref(), Some("rt123")); + Ok(()) + } + + #[tokio::test] + async fn poll_bails_on_access_denied() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token/")) + .respond_with( + ResponseTemplate::new(400).set_body_json(json!({"error": "access_denied"})), + ) + .mount(&server) + .await; + + let url = Url::parse(&format!("{}/token/", server.uri()))?; + let auth = test_auth(Duration::from_millis(10))?; + let result = poll_for_device_token(&url, "client-id", &auth, None, "test-agent").await; + + assert!(result.is_err()); + Ok(()) + } + + #[tokio::test] + async fn poll_attaches_dpop_header_with_c_s256_of_device_code() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token/")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "at123", + "expires_in": 3600 + }))) + .mount(&server) + .await; + + let url = Url::parse(&format!("{}/token/", server.uri()))?; + let auth = test_auth(Duration::from_millis(10))?; + let keypair = DpopKeyPair::generate(); + let result = + poll_for_device_token(&url, "client-id", &auth, Some(&keypair), "test-agent").await?; + + assert_eq!(result.access_token, "at123"); + + let requests = server + .received_requests() + .await + .ok_or_else(|| eyre!("request recording disabled"))?; + let req = requests + .first() + .ok_or_else(|| eyre!("no request received"))?; + let dpop_header = req + .headers + .get("dpop") + .ok_or_else(|| eyre!("missing DPoP header"))? + .to_str()?; + let parts: Vec<&str> = dpop_header.split('.').collect(); + assert_eq!(parts.len(), 3); + Ok(()) + } +} diff --git a/ak-platform/src/oauth/mod.rs b/ak-platform/src/oauth/mod.rs new file mode 100644 index 000000000..d5895be52 --- /dev/null +++ b/ak-platform/src/oauth/mod.rs @@ -0,0 +1 @@ +pub mod device_flow; diff --git a/protobuf/agent_ctrl.proto b/protobuf/agent_ctrl.proto index 032094585..08d7d6f79 100644 --- a/protobuf/agent_ctrl.proto +++ b/protobuf/agent_ctrl.proto @@ -21,6 +21,8 @@ message SetupRequest { string client_id = 4; string access_token = 5; string refresh_token = 6; + // PKCS#8 PEM DPoP private key; empty if this profile is not key-bound. + string dpop_private_key = 7; } message SetupResponse { @@ -33,6 +35,7 @@ message Profile { string authentik_url = 3; google.protobuf.Timestamp last_renewed = 4; google.protobuf.Timestamp next_renew = 5; + bool dpop_bound = 6; } message ListProfilesResponse { From 5707232970ccd815ddd5b3c224bdf4c2b16485af Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 5 Aug 2026 16:43:17 +0100 Subject: [PATCH 2/2] show currently active profile, format --- ak-cli/src/commands/config.rs | 56 ++++++++++++++++------------------- ak-cli/src/main.rs | 5 +--- ak-platform/src/grpc/log.rs | 5 +--- ak-platform/src/grpc/mod.rs | 2 +- ak-platform/src/log/mod.rs | 3 +- 5 files changed, 30 insertions(+), 41 deletions(-) diff --git a/ak-cli/src/commands/config.rs b/ak-cli/src/commands/config.rs index ba1fd20f7..c8ea12bf4 100644 --- a/ak-cli/src/commands/config.rs +++ b/ak-cli/src/commands/config.rs @@ -123,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) -> 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(()) + } + } } diff --git a/ak-cli/src/main.rs b/ak-cli/src/main.rs index 14b284def..b24c66bef 100644 --- a/ak-cli/src/main.rs +++ b/ak-cli/src/main.rs @@ -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 }, /// Configure authentik CLI Config { diff --git a/ak-platform/src/grpc/log.rs b/ak-platform/src/grpc/log.rs index e6d4d436d..6ea9cef09 100644 --- a/ak-platform/src/grpc/log.rs +++ b/ak-platform/src/grpc/log.rs @@ -35,10 +35,7 @@ pub struct TraceService { impl Service> for TraceService where - S: Service, Response = http::Response> - + Clone - + Send - + 'static, + S: Service, Response = http::Response> + Clone + Send + 'static, S::Future: Send + 'static, ReqBody: Send + 'static, { diff --git a/ak-platform/src/grpc/mod.rs b/ak-platform/src/grpc/mod.rs index ccb00057d..1ea8ea902 100644 --- a/ak-platform/src/grpc/mod.rs +++ b/ak-platform/src/grpc/mod.rs @@ -13,9 +13,9 @@ use crate::generated::agent::ResponseHeader; use crate::net; use crate::string::PlatformString; +pub mod log; pub mod method_caller; pub mod ssh; -pub mod log; pub async fn grpc_endpoint(path: String) -> Result { // Dummy URI to satisfy Endpoint::from() type requirements diff --git a/ak-platform/src/log/mod.rs b/ak-platform/src/log/mod.rs index f6f811379..a46e0265c 100644 --- a/ak-platform/src/log/mod.rs +++ b/ak-platform/src/log/mod.rs @@ -104,8 +104,7 @@ impl LogBuilder { .iter() .map(|(_, level)| *level) .fold(self.default_level, std::cmp::max); - let inner: Box = if self.allow_stdout && (env_interactive() || self.force_stdout) - { + let inner: Box = if self.allow_stdout && (env_interactive() || self.force_stdout) { self.get_stdout_logger() } else { match self.get_platform_logger() {