use serde_json::{Map, Value};
use tokio_util::sync::CancellationToken;
use url::Url;
use yapi_types::auth::OAuthCredential;
use super::callback::start_code_server;
use super::device::{DEVICE_CODE_GRANT, Poll, poll_device_code};
use super::{
AuthError, AuthEvent, AuthPrompt, BoxFuture, Interaction, LoginOptions, OAuthProvider, form,
now_ms, pkce, post_form, send,
};
const CALLBACK_HOST: &str = "127.0.0.1";
const CALLBACK_PATH: &str = "/oauth/callback";
const TOKEN_EXPIRY_SKEW_MS: u64 = 60_000;
const CLIENT_ID: &str = "pi-gateway";
const SCOPE: &str = "gateway offline_access";
pub const DEFAULT_GATEWAY: &str = "https://radius.pi.dev";
pub fn normalize_gateway(value: &str) -> String {
let lower = value.to_lowercase();
let with_scheme = if lower.starts_with("http://") || lower.starts_with("https://") {
value.to_owned()
} else {
format!("https://{value}")
};
with_scheme.trim_end_matches('/').to_owned()
}
#[derive(Clone, Debug)]
pub struct RadiusOAuth {
pub name: String,
pub gateway: String,
pub callback_port: u16,
}
impl RadiusOAuth {
pub fn new(name: &str, gateway: &str) -> RadiusOAuth {
RadiusOAuth {
name: name.to_owned(),
gateway: normalize_gateway(gateway),
callback_port: 1456,
}
}
fn redirect_uri(&self) -> String {
format!(
"http://{CALLBACK_HOST}:{}{CALLBACK_PATH}",
self.callback_port
)
}
fn url(&self, path: &str) -> String {
format!("{}{path}", self.gateway)
}
}
struct OAuthFailure {
message: String,
code: Option<String>,
}
async fn oauth_failure(response: reqwest::Response, message: &str) -> OAuthFailure {
let status = response.status().as_u16();
let text = response.text().await.unwrap_or_default();
let (code, description) = match serde_json::from_str::<Value>(&text) {
Ok(data) => (
data["error"].as_str().map(str::to_owned),
data["error_description"].as_str().map(str::to_owned),
),
Err(_) if !text.is_empty() => (None, Some(text)),
Err(_) => (None, None),
};
let detail = match (&code, &description) {
(Some(code), Some(description)) => format!("{code}: {description}"),
(Some(code), None) => code.clone(),
(None, Some(description)) if !description.is_empty() => description.clone(),
_ => status.to_string(),
};
OAuthFailure {
message: format!("{message}: {detail}"),
code,
}
}
impl RadiusOAuth {
async fn token(
&self,
fields: &[(&str, &str)],
cancel: &CancellationToken,
) -> Result<Result<OAuthCredential, OAuthFailure>, AuthError> {
let request = post_form(self.url("/v1/oauth/token"), fields);
let response = send(request, cancel).await?;
if !response.status().is_success() {
return Ok(Err(oauth_failure(
response,
"Radius OAuth token request failed",
)
.await));
}
let data = super::json_body(response).await;
let mut extra = Map::new();
if let Some(scope) = data["scope"].as_str() {
extra.insert("scope".into(), Value::String(scope.to_owned()));
}
let expires_in = data["expires_in"].as_f64().unwrap_or(0.0);
Ok(Ok(OAuthCredential {
access: data["access_token"].as_str().unwrap_or_default().to_owned(),
refresh: data["refresh_token"]
.as_str()
.unwrap_or_default()
.to_owned(),
expires: (now_ms() + (expires_in * 1000.0) as u64).saturating_sub(TOKEN_EXPIRY_SKEW_MS)
as f64,
extra,
}))
}
async fn login_browser(&self, interaction: &Interaction) -> Result<OAuthCredential, AuthError> {
let cancel = interaction.cancel();
let response = send(
crate::http::client()
.get(self.url("/v1/oauth"))
.header("accept", "application/json"),
cancel,
)
.await?;
let status = response.status().as_u16();
if !(200..300).contains(&status) {
let text = response.text().await.unwrap_or_default();
return Err(AuthError::Failed(format!(
"Could not load Radius OAuth config from {}: {status} {text}",
self.gateway
)));
}
let discovery = super::json_body(response).await;
let endpoint = discovery["authorizationEndpoint"].as_str().ok_or_else(|| {
AuthError::Failed(format!("Invalid Radius OAuth config from {}", self.gateway))
})?;
let pkce = pkce::generate()?;
let state = yapi_types::time::try_uuid_v4().map_err(AuthError::random)?;
let redirect_uri = self.redirect_uri();
let mut authorize =
Url::parse(endpoint).map_err(|err| AuthError::Failed(err.to_string()))?;
authorize.set_query(Some(&form(&[
("response_type", "code"),
("client_id", CLIENT_ID),
("redirect_uri", &redirect_uri),
("scope", SCOPE),
("code_challenge", &pkce.challenge),
("code_challenge_method", "S256"),
("handoff", "url"),
("state", &state),
])));
let mut server = start_code_server(
"Radius",
CALLBACK_HOST,
self.callback_port,
CALLBACK_PATH,
Some(state),
true,
cancel,
)
.await
.map_err(|err| AuthError::Failed(err.to_string()))?;
interaction.notify(AuthEvent::Progress {
message: format!("Listening for OAuth callback on {redirect_uri}"),
});
interaction.notify(AuthEvent::AuthUrl {
url: authorize.to_string(),
instructions: Some("Continue in your browser.".into()),
});
let (code, page) = server.wait_pending().await?;
let result = self
.token(
&[
("grant_type", "authorization_code"),
("client_id", CLIENT_ID),
("redirect_uri", &redirect_uri),
("code", &code),
("code_verifier", &pkce.verifier),
],
cancel,
)
.await
.and_then(|result| result.map_err(|failure| AuthError::Failed(failure.message)));
if let Some(page) = page {
page.finish("Radius", &result).await;
}
result
}
async fn login_device(&self, interaction: &Interaction) -> Result<OAuthCredential, AuthError> {
let cancel = interaction.cancel();
let request = post_form(
self.url("/v1/oauth/device"),
&[("client_id", CLIENT_ID), ("scope", SCOPE)],
);
let response = send(request, cancel).await?;
if !response.status().is_success() {
let failure = oauth_failure(response, "Radius OAuth device authorization failed").await;
return Err(AuthError::Failed(failure.message));
}
let data = super::json_body(response).await;
let (Some(device_code), Some(user_code), Some(verification_uri), Some(expires_in)) = (
data["device_code"].as_str().filter(|v| !v.is_empty()),
data["user_code"].as_str().filter(|v| !v.is_empty()),
data["verification_uri"].as_str().filter(|v| !v.is_empty()),
data["expires_in"].as_f64().filter(|v| *v != 0.0),
) else {
return Err(AuthError::failed(
"Radius OAuth device authorization response is missing required fields",
));
};
let interval = data["interval"].as_f64();
interaction.notify(AuthEvent::DeviceCode {
user_code: user_code.to_owned(),
verification_uri: verification_uri.to_owned(),
interval_seconds: interval,
expires_in_seconds: Some(expires_in),
});
poll_device_code(interval, Some(expires_in), false, cancel, || async {
match self
.token(
&[
("grant_type", DEVICE_CODE_GRANT),
("client_id", CLIENT_ID),
("device_code", device_code),
],
cancel,
)
.await?
{
Ok(credential) => Ok(Poll::Complete(credential)),
Err(failure) => match failure.code.as_deref() {
Some("authorization_pending") => Ok(Poll::Pending),
Some("slow_down") => Ok(Poll::SlowDown(None)),
Some("expired_token") => {
Ok(Poll::Failed("Device authorization expired.".into()))
}
Some("access_denied") => {
Ok(Poll::Failed("Device authorization was denied.".into()))
}
_ => Err(AuthError::Failed(failure.message)),
},
}
})
.await
}
async fn login_radius(&self, interaction: &Interaction) -> Result<OAuthCredential, AuthError> {
let method = interaction
.prompt(AuthPrompt::select(
format!("Sign in to {}:", self.name),
&[
("browser", "Sign in with browser (recommended)"),
(
"device-code",
"Sign in with device code (when signing in from another device)",
),
],
))
.await?;
match method.as_str() {
"device-code" => self.login_device(interaction).await,
"browser" => self.login_browser(interaction).await,
other => Err(AuthError::Failed(format!(
"Unknown {} sign-in method: {other}",
self.name
))),
}
}
}
impl OAuthProvider for RadiusOAuth {
fn name(&self) -> &str {
&self.name
}
fn is_subscription(&self) -> bool {
false
}
fn login<'a>(
&'a self,
interaction: &'a Interaction,
_options: &'a LoginOptions,
) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
Box::pin(self.login_radius(interaction))
}
fn refresh<'a>(
&'a self,
credential: &'a OAuthCredential,
cancel: &'a CancellationToken,
) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
Box::pin(async move {
self.token(
&[
("grant_type", "refresh_token"),
("client_id", CLIENT_ID),
("refresh_token", &credential.refresh),
],
cancel,
)
.await?
.map_err(|failure| AuthError::Failed(failure.message))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_gateways() {
assert_eq!(
normalize_gateway("radius.example.com/"),
"https://radius.example.com"
);
assert_eq!(
normalize_gateway("HTTP://127.0.0.1:9//"),
"HTTP://127.0.0.1:9"
);
}
}