use serde::de::DeserializeOwned;
use serde::Deserialize;
use super::flows::exchange_auth_code;
use super::login::{config_err, LoginIo};
use super::oauth::{parse_token_response, AuthError, Grant};
use super::oauth_row::DeviceStyle;
use super::refresh::collect_body;
use super::wire::{build_token_exchange_request, form_post};
use super::{auth_error, OAuthConfig};
use crate::canonical::CanonicalError;
use crate::protocol::WireRequest;
use crate::store::{Cred, Secret};
const DEFAULT_INTERVAL: u64 = 5;
const CODEX_DEADLINE: u64 = 900;
pub(super) fn device_flow(cfg: &OAuthConfig, io: &mut LoginIo) -> Result<Cred, CanonicalError> {
let device = cfg.device.as_ref().ok_or_else(|| {
config_err("this provider has no device endpoint; use `--browser`".to_owned())
})?;
match device.style {
DeviceStyle::Rfc8628 => rfc8628_flow(cfg, &device.url, io),
DeviceStyle::Codex => codex_flow(cfg, &device.url, io),
}
}
fn rfc8628_flow(
cfg: &OAuthConfig,
device_url: &str,
io: &mut LoginIo,
) -> Result<Cred, CanonicalError> {
let auth: DeviceAuth = parse_device_json(&collect_body(
io.transport
.send(form_post(device_url, &device_params(cfg)))?,
)?)?;
prompt(io, &auth.verification_uri, &auth.user_code);
let deadline = io.clock.now().saturating_add(auth.expires_in);
poll_until(
io,
deadline,
auth.interval.unwrap_or(DEFAULT_INTERVAL),
|io| {
let req = build_token_exchange_request(
cfg,
Grant::Device {
device_code: &auth.device_code,
},
);
match parse_token_response(&collect_body(io.transport.send(req)?)?, io.clock.now()) {
Ok(tok) => Ok(Step::Done(tok.as_cred(&Secret::new(""), &None, &None))),
Err(AuthError::Pending) => Ok(Step::Pending),
Err(AuthError::SlowDown) => Ok(Step::SlowDown),
Err(AuthError::Fatal(msg)) => {
Err(auth_error(&format!("device login failed: {msg}")))
}
}
},
)
}
fn codex_flow(cfg: &OAuthConfig, base: &str, io: &mut LoginIo) -> Result<Cred, CanonicalError> {
let body = serde_json::json!({ "client_id": cfg.client_id }).to_string();
let (status, bytes) = send(io, json_post(&format!("{base}/deviceauth/usercode"), body))?;
if !is_success(status) {
return Err(refused("device authorization", status, &bytes));
}
let auth: CodexUserCode = parse_device_json(&bytes)?;
prompt(io, &format!("{base}/codex/device"), &auth.user_code);
let deadline = io.clock.now().saturating_add(CODEX_DEADLINE);
let poll = serde_json::json!({
"device_auth_id": auth.device_auth_id,
"user_code": auth.user_code,
})
.to_string();
let url = format!("{base}/deviceauth/token");
let granted: CodexCode = poll_until(
io,
deadline,
auth.interval.unwrap_or(DEFAULT_INTERVAL),
|io| {
let (status, bytes) = send(io, json_post(&url, poll.clone()))?;
if is_success(status) {
return Ok(Step::Done(parse_device_json(&bytes)?));
}
if status == 403 || status == 404 {
return Ok(Step::Pending);
}
Err(refused("device poll", status, &bytes))
},
)?;
exchange_auth_code(
cfg,
io,
&granted.authorization_code,
&granted.code_verifier,
&format!("{base}/deviceauth/callback"),
)
}
enum Step<T> {
Pending,
SlowDown,
Done(T),
}
fn poll_until<T>(
io: &mut LoginIo,
deadline: u64,
mut interval: u64,
mut step: impl FnMut(&mut LoginIo) -> Result<Step<T>, CanonicalError>,
) -> Result<T, CanonicalError> {
loop {
if io.clock.now() >= deadline {
return Err(auth_error(
"device login expired before authorization; run `bz --login --provider <id>` again",
));
}
io.pacer.wait(interval);
match step(io)? {
Step::Done(value) => return Ok(value),
Step::Pending => {}
Step::SlowDown => interval += DEFAULT_INTERVAL,
}
}
}
fn prompt(io: &mut LoginIo, verification_uri: &str, user_code: &str) {
let _ = writeln!(
io.stderr,
"To authorize, open {verification_uri} and enter code: {user_code}"
);
}
#[derive(Deserialize)]
struct DeviceAuth {
device_code: String,
user_code: String,
verification_uri: String,
expires_in: u64,
interval: Option<u64>,
}
#[derive(Deserialize)]
struct CodexUserCode {
device_auth_id: String,
user_code: String,
interval: Option<u64>,
}
#[derive(Deserialize)]
struct CodexCode {
authorization_code: String,
code_verifier: String,
}
fn parse_device_json<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, CanonicalError> {
serde_json::from_slice(bytes)
.map_err(|e| auth_error(&format!("malformed device-authorization response: {e}")))
}
fn device_params(cfg: &OAuthConfig) -> Vec<(&str, &str)> {
let mut params = vec![("client_id", cfg.client_id.as_str())];
if let Some(scope) = &cfg.scope {
params.push(("scope", scope.as_str()));
}
params
}
fn json_post(url: &str, body: String) -> WireRequest {
let mut wire = WireRequest::new(url.to_owned(), body.into_bytes());
wire.set_header("content-type", "application/json");
wire
}
fn send(io: &mut LoginIo, wire: WireRequest) -> Result<(u16, Vec<u8>), CanonicalError> {
let resp = io.transport.send(wire)?;
let status = resp.status;
Ok((status, collect_body(resp)?))
}
fn is_success(status: u16) -> bool {
(200..300).contains(&status)
}
fn refused(stage: &str, status: u16, body: &[u8]) -> CanonicalError {
let detail = String::from_utf8_lossy(body);
auth_error(&format!(
"device login refused by the provider ({stage}, HTTP {status}): {}",
detail.trim()
))
}