feat: implement OAuth 2.0 Device Authorization Grant (RFC 8628)
Adds a third OAuthFlow variant (device_code) alongside the existing pkce and client_credentials flows. Device flow enables OAuth for headless environments where a browser-based callback listener isn't available — the user visits a verification URL on any device and enters a short user_code. - OAuthFlow::DeviceCode variant + serde 'device_code' string - OAuthConfig fields: device_authorization_url, use_pkce_in_device_flow - OAuthProvider trait: device_authorization_url() / use_pkce_in_device_flow() - OpenAICompatibleOAuthProvider passes both through from config - run_device_code_flow() polls the token endpoint per RFC 8628 §3.4–§3.5: handles authorization_pending, slow_down (+5s backoff), expired_token, access_denied, and unknown errors distinctly - Sandbox-gated QR code display (via qrcode crate) — scanning with a phone is dramatically faster than copy-pasting the URL from a container - Optional PKCE per draft-ietf-oauth-device-flow §5.4 (default off) - run_oauth_flow and prepare_oauth_access_token dispatchers wire DeviceCode in; refresh path shared with PKCE since both flows produce refresh_tokens
This commit is contained in:
+190
-1
@@ -32,6 +32,7 @@ pub enum OAuthFlow {
|
|||||||
#[default]
|
#[default]
|
||||||
Pkce,
|
Pkce,
|
||||||
ClientCredentials,
|
ClientCredentials,
|
||||||
|
DeviceCode,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -45,6 +46,7 @@ pub struct OAuthConfig {
|
|||||||
pub authorize_url: Option<String>,
|
pub authorize_url: Option<String>,
|
||||||
pub redirect_uri: Option<String>,
|
pub redirect_uri: Option<String>,
|
||||||
pub redirect_port: Option<u16>,
|
pub redirect_port: Option<u16>,
|
||||||
|
pub device_authorization_url: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub scopes: Vec<String>,
|
pub scopes: Vec<String>,
|
||||||
pub token_request_format: Option<TokenRequestFormat>,
|
pub token_request_format: Option<TokenRequestFormat>,
|
||||||
@@ -56,6 +58,8 @@ pub struct OAuthConfig {
|
|||||||
pub extra_request_headers: IndexMap<String, String>,
|
pub extra_request_headers: IndexMap<String, String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub echo_pkce_in_token_exchange: bool,
|
pub echo_pkce_in_token_exchange: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub use_pkce_in_device_flow: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub include_state_in_token_exchange: bool,
|
pub include_state_in_token_exchange: bool,
|
||||||
}
|
}
|
||||||
@@ -81,6 +85,9 @@ impl OAuthConfig {
|
|||||||
if override_cfg.redirect_port.is_some() {
|
if override_cfg.redirect_port.is_some() {
|
||||||
self.redirect_port = override_cfg.redirect_port;
|
self.redirect_port = override_cfg.redirect_port;
|
||||||
}
|
}
|
||||||
|
if override_cfg.device_authorization_url.is_some() {
|
||||||
|
self.device_authorization_url = override_cfg.device_authorization_url;
|
||||||
|
}
|
||||||
if !override_cfg.scopes.is_empty() {
|
if !override_cfg.scopes.is_empty() {
|
||||||
self.scopes = override_cfg.scopes;
|
self.scopes = override_cfg.scopes;
|
||||||
}
|
}
|
||||||
@@ -101,6 +108,7 @@ impl OAuthConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.echo_pkce_in_token_exchange = override_cfg.echo_pkce_in_token_exchange;
|
self.echo_pkce_in_token_exchange = override_cfg.echo_pkce_in_token_exchange;
|
||||||
|
self.use_pkce_in_device_flow = override_cfg.use_pkce_in_device_flow;
|
||||||
self.include_state_in_token_exchange = override_cfg.include_state_in_token_exchange;
|
self.include_state_in_token_exchange = override_cfg.include_state_in_token_exchange;
|
||||||
|
|
||||||
self
|
self
|
||||||
@@ -158,6 +166,14 @@ pub trait OAuthProvider: Send + Sync {
|
|||||||
fn echo_pkce_in_token_exchange(&self) -> bool {
|
fn echo_pkce_in_token_exchange(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn device_authorization_url(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn use_pkce_in_device_flow(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -174,6 +190,7 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
|
|||||||
match provider.flow() {
|
match provider.flow() {
|
||||||
OAuthFlow::Pkce => run_pkce_flow(provider, client_name).await,
|
OAuthFlow::Pkce => run_pkce_flow(provider, client_name).await,
|
||||||
OAuthFlow::ClientCredentials => run_client_credentials_flow(provider, client_name).await,
|
OAuthFlow::ClientCredentials => run_client_credentials_flow(provider, client_name).await,
|
||||||
|
OAuthFlow::DeviceCode => run_device_code_flow(provider, client_name).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +357,172 @@ async fn run_client_credentials_flow(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_device_code_flow(
|
||||||
|
provider: &dyn OAuthProvider,
|
||||||
|
client_name: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let device_auth_url = provider.device_authorization_url().ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"Provider '{}' is configured with flow: device_code but has no device_authorization_url. \
|
||||||
|
Set `oauth.device_authorization_url` in your config.",
|
||||||
|
provider.provider_name()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client = ReqwestClient::new();
|
||||||
|
|
||||||
|
let pkce = if provider.use_pkce_in_device_flow() {
|
||||||
|
let random_bytes: [u8; 32] = rand::random::<[u8; 32]>();
|
||||||
|
let verifier = URL_SAFE_NO_PAD.encode(random_bytes);
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(verifier.as_bytes());
|
||||||
|
let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
|
||||||
|
Some((verifier, challenge))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let scopes = provider.scopes();
|
||||||
|
let mut device_params: Vec<(&str, &str)> = vec![("client_id", provider.client_id())];
|
||||||
|
if !scopes.is_empty() {
|
||||||
|
device_params.push(("scope", scopes.as_str()));
|
||||||
|
}
|
||||||
|
if let Some((_, ref challenge)) = pkce {
|
||||||
|
device_params.push(("code_challenge", challenge.as_str()));
|
||||||
|
device_params.push(("code_challenge_method", "S256"));
|
||||||
|
}
|
||||||
|
let form: HashMap<&str, &str> = device_params.iter().copied().collect();
|
||||||
|
|
||||||
|
let mut device_request = client.post(device_auth_url).form(&form);
|
||||||
|
for (key, value) in provider.extra_token_headers() {
|
||||||
|
device_request = device_request.header(key, value);
|
||||||
|
}
|
||||||
|
let device_response: Value = device_request.send().await?.json().await?;
|
||||||
|
|
||||||
|
let device_code = device_response["device_code"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow!("Missing device_code in device authorization response: {device_response}")
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
let user_code = device_response["user_code"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow!("Missing user_code in device authorization response: {device_response}")
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
let verification_uri = device_response["verification_uri"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow!("Missing verification_uri in device authorization response: {device_response}")
|
||||||
|
})?
|
||||||
|
.to_string();
|
||||||
|
let verification_uri_complete = device_response["verification_uri_complete"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let expires_in = device_response["expires_in"].as_i64().unwrap_or(1800);
|
||||||
|
let mut interval = device_response["interval"].as_u64().unwrap_or(5);
|
||||||
|
let deadline = Utc::now().timestamp() + expires_in;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\nAuthenticate with {} (client '{}'):",
|
||||||
|
provider.provider_name(),
|
||||||
|
client_name
|
||||||
|
);
|
||||||
|
println!(" 1. Open: {verification_uri}");
|
||||||
|
println!(" 2. Enter code: {user_code}\n");
|
||||||
|
if let Some(complete) = &verification_uri_complete {
|
||||||
|
println!(" (Or open the pre-filled URL: {complete})\n");
|
||||||
|
}
|
||||||
|
let url_to_open = verification_uri_complete
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&verification_uri);
|
||||||
|
|
||||||
|
if std::env::var(crate::sandbox::SANDBOX_ENV_FLAG).is_ok()
|
||||||
|
&& let Ok(qr) = qrcode::QrCode::new(url_to_open)
|
||||||
|
{
|
||||||
|
let rendered = qr
|
||||||
|
.render::<qrcode::render::unicode::Dense1x2>()
|
||||||
|
.quiet_zone(true)
|
||||||
|
.build();
|
||||||
|
println!("{rendered}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = open::that(url_to_open);
|
||||||
|
|
||||||
|
println!("Waiting for authorization (polling every {interval}s)...\n");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
|
||||||
|
|
||||||
|
if Utc::now().timestamp() >= deadline {
|
||||||
|
bail!(
|
||||||
|
"Device code expired before user approval. Run `coyote --authenticate {}` to try again.",
|
||||||
|
client_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut token_params: Vec<(&str, &str)> = vec![
|
||||||
|
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||||
|
("device_code", device_code.as_str()),
|
||||||
|
("client_id", provider.client_id()),
|
||||||
|
];
|
||||||
|
if let Some((verifier, _)) = pkce.as_ref() {
|
||||||
|
token_params.push(("code_verifier", verifier.as_str()));
|
||||||
|
}
|
||||||
|
let token_response: Value = build_token_request(&client, provider, &token_params)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(access_token) = token_response["access_token"].as_str() {
|
||||||
|
let refresh_token = token_response["refresh_token"].as_str().map(str::to_string);
|
||||||
|
let expires_in_secs = token_response["expires_in"].as_i64().ok_or_else(|| {
|
||||||
|
anyhow!("Missing expires_in in device_code token response: {token_response}")
|
||||||
|
})?;
|
||||||
|
let expires_at = Utc::now().timestamp() + expires_in_secs;
|
||||||
|
let account_id = provider.extract_account_id(&token_response);
|
||||||
|
|
||||||
|
let tokens = OAuthTokens {
|
||||||
|
access_token: access_token.to_string(),
|
||||||
|
refresh_token,
|
||||||
|
expires_at,
|
||||||
|
account_id,
|
||||||
|
};
|
||||||
|
save_oauth_tokens(client_name, &tokens)?;
|
||||||
|
println!(
|
||||||
|
"Successfully authenticated client '{}' with {} via OAuth (device_code). Tokens saved.",
|
||||||
|
client_name,
|
||||||
|
provider.provider_name()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let error_code = token_response["error"].as_str().unwrap_or("");
|
||||||
|
match error_code {
|
||||||
|
"authorization_pending" => continue,
|
||||||
|
"slow_down" => {
|
||||||
|
interval += 5;
|
||||||
|
println!("Server requested slower polling; increasing interval to {interval}s.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
"expired_token" => bail!(
|
||||||
|
"Device code expired. Run `coyote --authenticate {}` to try again.",
|
||||||
|
client_name
|
||||||
|
),
|
||||||
|
"access_denied" => bail!("Authorization was denied by the user."),
|
||||||
|
other => bail!(
|
||||||
|
"Device code polling failed: {} — {}",
|
||||||
|
other,
|
||||||
|
token_response["error_description"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("no description")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_oauth_tokens(client_name: &str) -> Option<OAuthTokens> {
|
pub fn load_oauth_tokens(client_name: &str) -> Option<OAuthTokens> {
|
||||||
let path = paths::token_file(client_name);
|
let path = paths::token_file(client_name);
|
||||||
let content = fs::read_to_string(path).ok()?;
|
let content = fs::read_to_string(path).ok()?;
|
||||||
@@ -426,7 +609,9 @@ pub async fn prepare_oauth_access_token(
|
|||||||
|
|
||||||
let tokens = if Utc::now().timestamp() >= tokens.expires_at {
|
let tokens = if Utc::now().timestamp() >= tokens.expires_at {
|
||||||
match provider.flow() {
|
match provider.flow() {
|
||||||
OAuthFlow::Pkce => refresh_oauth_token(client, provider, client_name, &tokens).await?,
|
OAuthFlow::Pkce | OAuthFlow::DeviceCode => {
|
||||||
|
refresh_oauth_token(client, provider, client_name, &tokens).await?
|
||||||
|
}
|
||||||
OAuthFlow::ClientCredentials => {
|
OAuthFlow::ClientCredentials => {
|
||||||
run_client_credentials_flow(provider, client_name).await?;
|
run_client_credentials_flow(provider, client_name).await?;
|
||||||
load_oauth_tokens(client_name)
|
load_oauth_tokens(client_name)
|
||||||
@@ -664,12 +849,14 @@ mod tests {
|
|||||||
authorize_url: Some("https://base.example/authorize".into()),
|
authorize_url: Some("https://base.example/authorize".into()),
|
||||||
redirect_uri: None,
|
redirect_uri: None,
|
||||||
redirect_port: Some(1234),
|
redirect_port: Some(1234),
|
||||||
|
device_authorization_url: None,
|
||||||
scopes: vec!["a".into(), "b".into()],
|
scopes: vec!["a".into(), "b".into()],
|
||||||
token_request_format: Some(TokenRequestFormat::FormUrlEncoded),
|
token_request_format: Some(TokenRequestFormat::FormUrlEncoded),
|
||||||
extra_authorize_params: IndexMap::from([("plan".into(), "base".into())]),
|
extra_authorize_params: IndexMap::from([("plan".into(), "base".into())]),
|
||||||
extra_token_headers: IndexMap::new(),
|
extra_token_headers: IndexMap::new(),
|
||||||
extra_request_headers: IndexMap::new(),
|
extra_request_headers: IndexMap::new(),
|
||||||
echo_pkce_in_token_exchange: false,
|
echo_pkce_in_token_exchange: false,
|
||||||
|
use_pkce_in_device_flow: false,
|
||||||
include_state_in_token_exchange: true,
|
include_state_in_token_exchange: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -683,12 +870,14 @@ mod tests {
|
|||||||
authorize_url: None,
|
authorize_url: None,
|
||||||
redirect_uri: None,
|
redirect_uri: None,
|
||||||
redirect_port: None,
|
redirect_port: None,
|
||||||
|
device_authorization_url: None,
|
||||||
scopes: vec![],
|
scopes: vec![],
|
||||||
token_request_format: None,
|
token_request_format: None,
|
||||||
extra_authorize_params: IndexMap::new(),
|
extra_authorize_params: IndexMap::new(),
|
||||||
extra_token_headers: IndexMap::new(),
|
extra_token_headers: IndexMap::new(),
|
||||||
extra_request_headers: IndexMap::new(),
|
extra_request_headers: IndexMap::new(),
|
||||||
echo_pkce_in_token_exchange: false,
|
echo_pkce_in_token_exchange: false,
|
||||||
|
use_pkce_in_device_flow: false,
|
||||||
include_state_in_token_exchange: true,
|
include_state_in_token_exchange: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,4 +89,12 @@ impl OAuthProvider for OpenAICompatibleOAuthProvider {
|
|||||||
fn echo_pkce_in_token_exchange(&self) -> bool {
|
fn echo_pkce_in_token_exchange(&self) -> bool {
|
||||||
self.config.echo_pkce_in_token_exchange
|
self.config.echo_pkce_in_token_exchange
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn device_authorization_url(&self) -> Option<&str> {
|
||||||
|
self.config.device_authorization_url.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn use_pkce_in_device_flow(&self) -> bool {
|
||||||
|
self.config.use_pkce_in_device_flow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user