use anyhow::{Context, Result, anyhow};
use chrono::{TimeDelta, Utc};
use openidconnect::core::{CoreClient, CoreDeviceAuthorizationResponse};
use openidconnect::{
DeviceAuthorizationUrl, ErrorResponseType, OAuth2TokenResponse, RequestTokenError, Scope,
StandardErrorResponse, TokenResponse,
};
use secrecy::SecretString;
use super::{OidcConfig, OidcTokens};
pub async fn run(cfg: &OidcConfig, http: &reqwest::Client) -> Result<OidcTokens> {
let metadata = super::discovery::load_provider_metadata(cfg, http).await?;
let device_url = match &metadata.additional_metadata().device_authorization_endpoint {
Some(url) => url.clone(),
None => conventional_device_endpoint(cfg)?,
};
let client = CoreClient::from_provider_metadata(metadata, cfg.client_id.clone(), None)
.set_device_authorization_url(device_url);
let mut request = client.exchange_device_code();
for scope in &cfg.scopes {
request = request.add_scope(Scope::new(scope.clone()));
}
if let Some(audience) = cfg.audience.as_deref() {
request = request.add_extra_param("audience", audience);
}
let details: CoreDeviceAuthorizationResponse = request
.request_async(http)
.await
.map_err(|e| device_error("requesting device code", e))?;
print_user_instructions(&details);
let polling = client
.exchange_device_access_token(&details)
.context("building device-access-token request")?
.request_async(http, tokio::time::sleep, None);
let token_response = tokio::select! {
response = polling => response.map_err(|e| device_error("polling for device token", e))?,
signal = tokio::signal::ctrl_c() => {
signal.context("listening for device login cancellation")?;
return Err(anyhow!("device login cancelled; no session was saved"));
}
};
Ok(tokens_from_response(&token_response))
}
fn print_user_instructions(details: &CoreDeviceAuthorizationResponse) {
let verification_uri = details.verification_uri().as_str();
let user_code = details.user_code().secret();
eprintln!();
eprintln!("To finish logging in, open this URL in any browser:");
eprintln!(" {verification_uri}");
eprintln!("and enter this one-time code:");
eprintln!(" {user_code}");
if let Some(complete) = details.verification_uri_complete() {
let complete = complete.secret();
eprintln!();
eprintln!("Or open this single-step URL (the code is pre-filled):");
eprintln!(" {complete}");
let _ = webbrowser::open(complete);
}
eprintln!();
}
fn conventional_device_endpoint(cfg: &OidcConfig) -> Result<DeviceAuthorizationUrl> {
let url = format!("{}/device/code", cfg.issuer.as_str().trim_end_matches('/'));
DeviceAuthorizationUrl::new(url.clone())
.with_context(|| format!("invalid device authorization URL: {url}"))
}
fn device_error<RE, TE>(
stage: &str,
error: RequestTokenError<RE, StandardErrorResponse<TE>>,
) -> anyhow::Error
where
RE: std::error::Error + 'static,
TE: ErrorResponseType + std::fmt::Display + 'static,
{
let message = match error {
RequestTokenError::ServerResponse(response) => {
match response.error().to_string().as_str() {
"unauthorized_client" => {
"unauthorized_client: check the public CLI client ID and issuer; confirm the Device Code grant is enabled for that CLI application (--client-id overrides server config)"
}
"invalid_client" => "invalid_client: check the public CLI client ID",
"access_denied" => {
"access_denied: device authorization was denied; start login again"
}
"expired_token" => "expired_token: the device code expired; start login again",
"invalid_grant" => {
"invalid_grant: device authorization is no longer valid; start login again"
}
"invalid_scope" => "invalid_scope: check the CLI client's allowed scopes",
_ => "the identity provider rejected device authorization",
}
}
RequestTokenError::Request(_) => {
"unable to reach the identity provider; check connectivity and retry"
}
RequestTokenError::Parse(_, _) => {
"invalid OAuth response; check the provider's discovery document and device endpoint"
}
RequestTokenError::Other(_) => "device authorization could not complete; start login again",
};
anyhow!("{stage}: {message}")
}
fn tokens_from_response(resp: &openidconnect::core::CoreTokenResponse) -> OidcTokens {
let expires_in = resp
.expires_in()
.and_then(|d| TimeDelta::from_std(d).ok())
.unwrap_or(TimeDelta::minutes(5));
OidcTokens {
access_token: SecretString::from(resp.access_token().secret().clone()),
refresh_token: resp
.refresh_token()
.map(|t| SecretString::from(t.secret().clone())),
id_token: resp.id_token().map(|t| SecretString::from(t.to_string())),
expires_at: Utc::now() + expires_in,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn conventional_endpoint_handles_trailing_slash() {
let cfg = OidcConfig {
issuer: openidconnect::IssuerUrl::new("https://dex.boxlite.ai/dex/".to_string())
.unwrap(),
client_id: openidconnect::ClientId::new("boxlite".to_string()),
audience: None,
scopes: vec![],
};
let url = conventional_device_endpoint(&cfg).unwrap();
assert_eq!(url.as_str(), "https://dex.boxlite.ai/dex/device/code");
}
}