use rmcp::schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::context::ToolContext;
use crate::error::{ToolError, ToolResult};
use crate::tools::common::{Done, each_present, path_segment, report};
crate::tools! {
tailnet_oauth_app_list => NoParams, oauth_app_list,
toolset: TailnetOauthApps, tier: Read, idempotent: true;
tailnet_oauth_app_create => OauthAppWriteParams, oauth_app_create,
toolset: TailnetOauthApps, tier: Write, idempotent: false;
tailnet_oauth_app_get => OauthAppParams, oauth_app_get,
toolset: TailnetOauthApps, tier: Read, idempotent: true;
tailnet_oauth_app_update => OauthAppUpdateParams, oauth_app_update,
toolset: TailnetOauthApps, tier: Write, idempotent: true;
tailnet_oauth_app_delete => OauthAppParams, oauth_app_delete,
toolset: TailnetOauthApps, tier: Destructive, idempotent: true;
}
fn oauth_apps_path(client: &tailscale_rest::Client) -> String {
client.tailnet_path(None, "/oauth-apps")
}
fn oauth_app_path(client: &tailscale_rest::Client, app_id: &str) -> ToolResult<String> {
Ok(client.tailnet_path(
None,
&format!("/oauth-apps/{}", path_segment("app_id", app_id)?),
))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NoParams {}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct OauthAppParams {
pub app_id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct OauthAppWriteParams {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub redirect_uris: Vec<String>,
pub scopes: Vec<String>,
#[serde(default)]
pub allowed_node_attributes: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct OauthAppUpdateParams {
pub app_id: String,
#[serde(flatten)]
pub app: OauthAppWriteParams,
}
#[derive(Debug, Serialize)]
struct OauthAppBody {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(rename = "redirectURIs")]
redirect_uris: Vec<String>,
scopes: Vec<String>,
#[serde(
rename = "allowedNodeAttributes",
skip_serializing_if = "Option::is_none"
)]
allowed_node_attributes: Option<Vec<String>>,
}
impl OauthAppBody {
fn build(app: OauthAppWriteParams) -> ToolResult<Self> {
let redirect_uris = each_present("redirect_uris", app.redirect_uris)?;
if redirect_uris.is_empty() {
return Err(ToolError::invalid_args(
"`redirect_uris` is empty; an OAuth app needs somewhere to return to",
));
}
let scopes = each_present("scopes", app.scopes)?;
if scopes.is_empty() {
return Err(ToolError::invalid_args(
"`scopes` is empty; an OAuth app with no scope can do nothing",
));
}
Ok(Self {
name: app.name,
description: app.description,
redirect_uris,
scopes,
allowed_node_attributes: app
.allowed_node_attributes
.map(|attributes| each_present("allowed_node_attributes", attributes))
.transpose()?,
})
}
}
async fn oauth_app_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(oauth_apps_path(client))
.send_as::<Value>()
.await?)
}
async fn oauth_app_create(ctx: &ToolContext, params: OauthAppWriteParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let body = OauthAppBody::build(params)?;
Ok(client
.post(oauth_apps_path(client))
.json(&body)
.send_as::<Value>()
.await?)
}
async fn oauth_app_get(ctx: &ToolContext, params: OauthAppParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(oauth_app_path(client, ¶ms.app_id)?)
.send_as::<Value>()
.await?)
}
async fn oauth_app_update(ctx: &ToolContext, params: OauthAppUpdateParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = oauth_app_path(client, ¶ms.app_id)?;
let body = OauthAppBody::build(params.app)?;
Ok(client.put(path).json(&body).send_as::<Value>().await?)
}
async fn oauth_app_delete(ctx: &ToolContext, params: OauthAppParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = oauth_app_path(client, ¶ms.app_id)?;
client.delete(path).send().await?;
report(Done::new("oauth app deleted").about("app_id", params.app_id))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_body_is_sent_in_tailscales_spelling() {
let body = OauthAppBody::build(OauthAppWriteParams {
name: "my-oauth-app".to_owned(),
description: None,
redirect_uris: vec!["https://example.com/oauth/callback".to_owned()],
scopes: vec!["auth_keys:create".to_owned()],
allowed_node_attributes: None,
})
.expect("valid");
assert_eq!(
serde_json::to_value(&body).expect("serialisable"),
json!({
"name": "my-oauth-app",
"redirectURIs": ["https://example.com/oauth/callback"],
"scopes": ["auth_keys:create"],
}),
"an absent optional field is absent, not null"
);
}
#[test]
fn a_registration_that_could_not_work_is_refused_before_it_is_sent() {
let bare = |redirect_uris: Vec<String>, scopes: Vec<String>| OauthAppWriteParams {
name: "my-oauth-app".to_owned(),
description: None,
redirect_uris,
scopes,
allowed_node_attributes: None,
};
let uri = || vec!["https://example.com/cb".to_owned()];
let scope = || vec!["auth_keys:create".to_owned()];
assert!(OauthAppBody::build(bare(vec![], scope())).is_err());
assert!(OauthAppBody::build(bare(uri(), vec![])).is_err());
assert!(OauthAppBody::build(bare(vec![String::new()], scope())).is_err());
}
}