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:
2026-07-20 15:21:32 -06:00
parent d13bd32fdf
commit 0fe430102a
2 changed files with 198 additions and 1 deletions
+190 -1
View File
@@ -32,6 +32,7 @@ pub enum OAuthFlow {
#[default]
Pkce,
ClientCredentials,
DeviceCode,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -45,6 +46,7 @@ pub struct OAuthConfig {
pub authorize_url: Option<String>,
pub redirect_uri: Option<String>,
pub redirect_port: Option<u16>,
pub device_authorization_url: Option<String>,
#[serde(default)]
pub scopes: Vec<String>,
pub token_request_format: Option<TokenRequestFormat>,
@@ -56,6 +58,8 @@ pub struct OAuthConfig {
pub extra_request_headers: IndexMap<String, String>,
#[serde(default)]
pub echo_pkce_in_token_exchange: bool,
#[serde(default)]
pub use_pkce_in_device_flow: bool,
#[serde(default = "default_true")]
pub include_state_in_token_exchange: bool,
}
@@ -81,6 +85,9 @@ impl OAuthConfig {
if override_cfg.redirect_port.is_some() {
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() {
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.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
@@ -158,6 +166,14 @@ pub trait OAuthProvider: Send + Sync {
fn echo_pkce_in_token_exchange(&self) -> bool {
false
}
fn device_authorization_url(&self) -> Option<&str> {
None
}
fn use_pkce_in_device_flow(&self) -> bool {
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -174,6 +190,7 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
match provider.flow() {
OAuthFlow::Pkce => run_pkce_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(())
}
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> {
let path = paths::token_file(client_name);
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 {
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 => {
run_client_credentials_flow(provider, client_name).await?;
load_oauth_tokens(client_name)
@@ -664,12 +849,14 @@ mod tests {
authorize_url: Some("https://base.example/authorize".into()),
redirect_uri: None,
redirect_port: Some(1234),
device_authorization_url: None,
scopes: vec!["a".into(), "b".into()],
token_request_format: Some(TokenRequestFormat::FormUrlEncoded),
extra_authorize_params: IndexMap::from([("plan".into(), "base".into())]),
extra_token_headers: IndexMap::new(),
extra_request_headers: IndexMap::new(),
echo_pkce_in_token_exchange: false,
use_pkce_in_device_flow: false,
include_state_in_token_exchange: true,
}
}
@@ -683,12 +870,14 @@ mod tests {
authorize_url: None,
redirect_uri: None,
redirect_port: None,
device_authorization_url: None,
scopes: vec![],
token_request_format: None,
extra_authorize_params: IndexMap::new(),
extra_token_headers: IndexMap::new(),
extra_request_headers: IndexMap::new(),
echo_pkce_in_token_exchange: false,
use_pkce_in_device_flow: false,
include_state_in_token_exchange: true,
}
}