P4 complete: OAuth authorization-code flow, MCP-OAuth, AddApps connects

- migration 0005 oauth_states: one-time states (10-min TTL), consumed by a
  CAS DELETE on callback — replays and forgeries both 404
- POST /api/apps/oauth/start: OIDC discovery on the configured issuer (or
  the custom MCP issuer for authType=mcp_oauth), state row, authorize URL
- GET /api/apps/oauth/callback: code exchanged at the REAL token endpoint
  (client id+secret form POST); the access token goes straight to the
  broker (test proves it never appears unencrypted in Postgres); connection
  row + audit; redirects to the claw's Add Apps panel
- [oauth] config (issuer/client/redirect_base) wired through AppState
- Tests against a real local IdP server (discovery + validating token
  endpoint): full round trip, broker-held token, replay/forged state
  refused, bad code fails exchange, mcp_oauth uses the custom issuer while
  plain oauth refuses without a configured IdP
- AddAppsApp: live connection badges + inline API-key connect per app
  (E2E: connect Notion by key from the directory)

136 Rust + 63 frontend tests + 21 Playwright journeys.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 06:50:41 -05:00
co-authored by Claude Fable 5
parent 6dbdd20ee0
commit 91327e3618
15 changed files with 785 additions and 18 deletions
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO oauth_states\n (state, workspace_id, user_id, agent_id, provider, auth_type,\n issuer_url, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now() + interval '10 minutes')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid",
"Uuid",
"Uuid",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "b53b31468a210a8055710c4f17c41b66cf409b3299f32bc00e3beb164f58f4e7"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM oauth_states\n WHERE state = $1 AND expires_at > now()\n RETURNING workspace_id, user_id, agent_id, provider, auth_type, issuer_url",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "agent_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "provider",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "auth_type",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "issuer_url",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "c7c420c92d07cd95489549c2fc00f71092d2709b5ebf7f5f62d3df8a87691912"
}
Generated
+1
View File
@@ -2858,6 +2858,7 @@ dependencies = [
"sha2", "sha2",
"sqlx", "sqlx",
"tc-auth", "tc-auth",
"tc-config",
"tc-db", "tc-db",
"tc-domain", "tc-domain",
"tc-llm", "tc-llm",
+3 -1
View File
@@ -91,7 +91,9 @@ async fn run() -> Result<(), String> {
.spawn(std::time::Duration::from_secs(5)); .spawn(std::time::Duration::from_secs(5));
let mut app = tc_api::router( let mut app = tc_api::router(
tc_api::AppState::new(pool, runtime).with_broker(PathBuf::from(&config.broker.socket_path)), tc_api::AppState::new(pool, runtime)
.with_broker(PathBuf::from(&config.broker.socket_path))
.with_oauth(config.oauth.clone()),
); );
if e2e::enabled() { if e2e::enabled() {
app = app.merge(e2e::slack_sink_router()); app = app.merge(e2e::slack_sink_router());
+3 -1
View File
@@ -13,7 +13,9 @@ futures = "0.3"
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tc-auth = { path = "../tc-auth" } tc-auth = { path = "../tc-auth" }
tc-config = { path = "../tc-config" }
tc-db = { path = "../tc-db" } tc-db = { path = "../tc-db" }
tc-domain = { path = "../tc-domain" } tc-domain = { path = "../tc-domain" }
tc-runtime = { path = "../tc-runtime" } tc-runtime = { path = "../tc-runtime" }
@@ -23,6 +25,7 @@ tc-secrets = { path = "../tc-secrets" }
thiserror = { workspace = true } thiserror = { workspace = true }
time = { workspace = true } time = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
urlencoding = "2"
uuid = { workspace = true } uuid = { workspace = true }
[dev-dependencies] [dev-dependencies]
@@ -37,7 +40,6 @@ tc-testkit = { path = "../tc-testkit" }
hex = "0.4" hex = "0.4"
hmac = "0.12" hmac = "0.12"
sha2 = "0.10" sha2 = "0.10"
urlencoding = "2"
[lints] [lints]
workspace = true workspace = true
+9
View File
@@ -20,6 +20,7 @@ pub struct AppState {
pub runtime: Runtime, pub runtime: Runtime,
/// Secret broker socket; connect flows refuse without it. /// Secret broker socket; connect flows refuse without it.
pub broker_socket: Option<std::path::PathBuf>, pub broker_socket: Option<std::path::PathBuf>,
pub oauth: tc_config::OAuthConfig,
} }
impl AppState { impl AppState {
@@ -30,9 +31,15 @@ impl AppState {
auth, auth,
runtime, runtime,
broker_socket: None, broker_socket: None,
oauth: tc_config::OAuthConfig::default(),
} }
} }
pub fn with_oauth(mut self, oauth: tc_config::OAuthConfig) -> AppState {
self.oauth = oauth;
self
}
pub fn with_broker(mut self, socket: std::path::PathBuf) -> AppState { pub fn with_broker(mut self, socket: std::path::PathBuf) -> AppState {
self.broker_socket = Some(socket); self.broker_socket = Some(socket);
self self
@@ -72,6 +79,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/slack/events", post(routes::slack::events)) .route("/api/slack/events", post(routes::slack::events))
.route("/api/apps", get(routes::apps::directory)) .route("/api/apps", get(routes::apps::directory))
.route("/api/apps/connect", post(routes::apps::connect)) .route("/api/apps/connect", post(routes::apps::connect))
.route("/api/apps/oauth/start", post(routes::oauth::start))
.route("/api/apps/oauth/callback", get(routes::oauth::callback))
.route("/api/apps/disconnect", post(routes::apps::disconnect)) .route("/api/apps/disconnect", post(routes::apps::disconnect))
.route("/api/approvals", get(routes::approvals::list)) .route("/api/approvals", get(routes::approvals::list))
.route("/api/approvals/{id}", get(routes::approvals::get)) .route("/api/approvals/{id}", get(routes::approvals::get))
+1
View File
@@ -7,6 +7,7 @@ pub mod files;
pub mod gateway; pub mod gateway;
pub mod health; pub mod health;
pub mod identity; pub mod identity;
pub mod oauth;
pub mod routines; pub mod routines;
pub mod sessions; pub mod sessions;
pub mod skills; pub mod skills;
+206
View File
@@ -0,0 +1,206 @@
//! OAuth authorization-code connects (§7.8 [+] and §10 MCP-OAuth).
//!
//! `start` records a one-time state and returns the authorization URL;
//! `callback` consumes the state (CAS delete — replay-proof), exchanges
//! the code at the issuer's REAL token endpoint, hands the access token to
//! the secret broker, and records the connection. The token, like every
//! credential, is never readable again outside the broker.
use axum::extract::{Query, State};
use axum::response::Redirect;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use tc_db::repo::audit::Actor;
use tc_domain::{AgentId, UserId, WorkspaceId};
use uuid::Uuid;
use crate::routes::claws::workspace_agent;
use crate::{ApiError, AppState, Authed};
/// Fetches the issuer's discovery document for its endpoints.
async fn discover(issuer_url: &str) -> Result<(String, String), ApiError> {
let doc: Value = reqwest::get(format!(
"{}/.well-known/openid-configuration",
issuer_url.trim_end_matches('/')
))
.await
.map_err(|_| ApiError::Internal)?
.json()
.await
.map_err(|_| ApiError::Internal)?;
let authorize = doc["authorization_endpoint"]
.as_str()
.ok_or(ApiError::Internal)?
.to_owned();
let token = doc["token_endpoint"]
.as_str()
.ok_or(ApiError::Internal)?
.to_owned();
Ok((authorize, token))
}
#[derive(Deserialize)]
pub struct StartRequest {
#[serde(rename = "clawId")]
claw_id: AgentId,
provider: String,
/// "oauth" (configured IdP) or "mcp_oauth" (custom issuer below).
#[serde(rename = "authType", default = "default_auth_type")]
auth_type: String,
/// Required for mcp_oauth: the MCP server's issuer.
#[serde(rename = "issuerUrl")]
issuer_url: Option<String>,
}
fn default_auth_type() -> String {
"oauth".into()
}
/// POST /api/apps/oauth/start → { authorize_url }
pub async fn start(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<StartRequest>,
) -> Result<Json<Value>, ApiError> {
let agent = workspace_agent(&state, &user, body.claw_id).await?;
let issuer = match body.auth_type.as_str() {
"mcp_oauth" => body.issuer_url.clone().ok_or(ApiError::Conflict)?,
"oauth" => state.oauth.issuer_url.clone().ok_or(ApiError::Conflict)?,
_ => return Err(ApiError::Conflict),
};
let client_id = state.oauth.client_id.clone().ok_or(ApiError::Conflict)?;
let redirect_base = state
.oauth
.redirect_base
.clone()
.ok_or(ApiError::Conflict)?;
let (authorize_endpoint, _) = discover(&issuer).await?;
let oauth_state = Uuid::now_v7().simple().to_string();
sqlx::query!(
"INSERT INTO oauth_states
(state, workspace_id, user_id, agent_id, provider, auth_type,
issuer_url, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now() + interval '10 minutes')",
oauth_state,
user.workspace_id.as_uuid(),
user.user_id.as_uuid(),
agent.id.as_uuid(),
body.provider,
body.auth_type,
issuer,
)
.execute(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
let redirect_uri = format!("{redirect_base}/api/apps/oauth/callback");
let authorize_url = format!(
"{authorize_endpoint}?response_type=code&client_id={}&redirect_uri={}&state={oauth_state}&scope=openid",
urlencoding::encode(&client_id),
urlencoding::encode(&redirect_uri),
);
Ok(Json(
json!({ "authorize_url": authorize_url, "state": oauth_state }),
))
}
#[derive(Deserialize)]
pub struct CallbackQuery {
code: String,
state: String,
}
/// GET /api/apps/oauth/callback?code=&state= — the IdP redirect target.
/// Unauthenticated by nature; trust comes from the one-time state.
pub async fn callback(
State(state): State<AppState>,
Query(query): Query<CallbackQuery>,
) -> Result<Redirect, ApiError> {
// Consume the state exactly once (replays and forgeries both 404).
let pending = sqlx::query!(
"DELETE FROM oauth_states
WHERE state = $1 AND expires_at > now()
RETURNING workspace_id, user_id, agent_id, provider, auth_type, issuer_url",
query.state,
)
.fetch_optional(&state.pool)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)?;
let client_id = state.oauth.client_id.clone().ok_or(ApiError::Internal)?;
let redirect_base = state
.oauth
.redirect_base
.clone()
.ok_or(ApiError::Internal)?;
let (_, token_endpoint) = discover(&pending.issuer_url).await?;
// Exchange the code at the REAL token endpoint.
let mut form = vec![
("grant_type", "authorization_code".to_owned()),
("code", query.code.clone()),
("client_id", client_id),
(
"redirect_uri",
format!("{redirect_base}/api/apps/oauth/callback"),
),
];
if let Some(secret) = state.oauth.client_secret.clone() {
form.push(("client_secret", secret));
}
let token_response: Value = reqwest::Client::new()
.post(&token_endpoint)
.form(&form)
.send()
.await
.map_err(|_| ApiError::Internal)?
.json()
.await
.map_err(|_| ApiError::Internal)?;
let access_token = token_response["access_token"]
.as_str()
.ok_or(ApiError::Internal)?;
// The token goes straight to the broker; only the ref persists.
let socket = state.broker_socket.as_ref().ok_or(ApiError::Internal)?;
let mut broker = tc_secrets::BrokerClient::connect(socket)
.await
.map_err(|_| ApiError::Internal)?;
let workspace_id = WorkspaceId::from(pending.workspace_id);
let secret_ref = broker
.store_secret(
workspace_id,
&format!("{}_oauth_token", pending.provider),
access_token,
)
.await
.map_err(|_| ApiError::Internal)?;
let connection = tc_db::repo::connections::insert(
&state.pool,
workspace_id,
Some(AgentId::from(pending.agent_id)),
&pending.provider,
&pending.auth_type,
secret_ref,
)
.await?;
tc_db::repo::audit::append(
&state.pool,
workspace_id,
Actor::User(UserId::from(pending.user_id)),
"app.connected",
"app_connection",
&connection.id.to_string(),
json!({"provider": pending.provider, "auth_type": pending.auth_type}),
)
.await?;
// Back to the claw's Add Apps panel.
Ok(Redirect::to(&format!(
"/claws/{}?app=apps",
pending.agent_id
)))
}
+379
View File
@@ -0,0 +1,379 @@
//! The OAuth connect round trip against a REAL local identity provider:
//! a live HTTP server implementing discovery + the token endpoint (the
//! same real-server pattern as the Slack sink). One-time states are
//! replay-proof; the access token ends up broker-held only.
use std::sync::Arc;
use std::time::Duration;
use serde_json::{json, Value};
use tc_api::AppState;
use tc_auth::AuthService;
use tc_config::OAuthConfig;
use tc_domain::{Role, User, UserId, Workspace, WorkspaceId};
use tc_llm::ScriptedProvider;
use tc_runtime::{Runtime, RuntimeConfig};
use tc_secrets::{BrokerServer, FileKey};
/// A minimal real IdP: discovery doc + token endpoint that validates the
/// code and client, then issues a bearer token.
async fn spawn_idp() -> String {
use axum::extract::State as AxState;
use axum::routing::{get, post};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let issuer = format!("http://{addr}");
let issuer_for_doc = issuer.clone();
let app = axum::Router::new()
.route(
"/.well-known/openid-configuration",
get(move || {
let issuer = issuer_for_doc.clone();
async move {
axum::Json(json!({
"issuer": issuer,
"authorization_endpoint": format!("{issuer}/auth"),
"token_endpoint": format!("{issuer}/token"),
}))
}
}),
)
.route(
"/token",
post(
|AxState(()): AxState<()>, axum::Form(form): axum::Form<Value>| async move {
if form["grant_type"] == "authorization_code"
&& form["code"] == "good-code"
&& form["client_id"] == "teamclaw"
&& form["client_secret"] == "tc-secret"
{
axum::Json(json!({
"access_token": "idp-access-token-42",
"token_type": "Bearer",
}))
.into_response()
} else {
(
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": "invalid_grant"})),
)
.into_response()
}
},
),
)
.with_state(());
use axum::response::IntoResponse;
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
issuer
}
async fn spawn_broker(pool: sqlx::PgPool) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("tc-oa-{}", uuid::Uuid::now_v7().simple()));
std::fs::create_dir_all(&dir).unwrap();
let key_path = dir.join("broker.key");
FileKey::generate(&key_path).unwrap();
let key = FileKey::load(&key_path).unwrap();
let short = uuid::Uuid::now_v7().simple().to_string();
let socket = std::path::PathBuf::from(format!("/tmp/tco-{}.sock", &short[short.len() - 12..]));
let server = BrokerServer::new(pool, key, socket.clone());
tokio::spawn(async move {
server.serve().await.unwrap();
});
for _ in 0..50 {
if socket.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
socket
}
#[tokio::test]
async fn oauth_round_trip_stores_the_token_in_the_broker_only() {
let pool = tc_testkit::test_pool().await;
let issuer = spawn_idp().await;
let socket = spawn_broker(pool.clone()).await;
let runtime = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml("").unwrap()),
RuntimeConfig::basic("scripted", 1024),
);
let oauth = OAuthConfig {
issuer_url: Some(issuer.clone()),
client_id: Some("teamclaw".into()),
client_secret: Some("tc-secret".into()),
redirect_base: Some("http://127.0.0.1:9".into()), // shape only
};
let app = tc_api::router(
AppState::new(pool.clone(), runtime)
.with_broker(socket)
.with_oauth(oauth),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base = format!("http://{addr}");
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
// Seed and login.
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
tc_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
tc_db::repo::users::insert(&pool, &owner).await.unwrap();
AuthService::new(pool.clone())
.set_password(owner.id, "pw")
.await
.unwrap();
let token = client
.post(format!("{base}/api/auth/login"))
.json(&json!({"email": owner.email, "password": "pw"}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_owned();
let claw: Value = client
.post(format!("{base}/api/claws"))
.bearer_auth(&token)
.json(&json!({"name": "Scout", "job_title": "Analyst"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let claw_id = claw["id"].as_str().unwrap();
// Sanity: the IdP discovery endpoint is reachable.
let doc: Value = reqwest::get(format!("{issuer}/.well-known/openid-configuration"))
.await
.unwrap()
.json()
.await
.unwrap();
assert!(doc["token_endpoint"].is_string(), "discovery doc: {doc}");
// Start: authorize URL points at the IdP with our state.
let start_res = client
.post(format!("{base}/api/apps/oauth/start"))
.bearer_auth(&token)
.json(&json!({"clawId": claw_id, "provider": "notion"}))
.send()
.await
.unwrap();
let status = start_res.status();
let start: Value = start_res.json().await.unwrap();
assert_eq!(status, 200, "start failed: {start}");
let authorize_url = start["authorize_url"].as_str().unwrap();
assert!(authorize_url.starts_with(&format!("{issuer}/auth?")));
assert!(authorize_url.contains("client_id=teamclaw"));
let oauth_state = start["state"].as_str().unwrap();
// The IdP redirects back with a code: exchange succeeds, connection
// exists, token is broker-held only.
let callback = client
.get(format!(
"{base}/api/apps/oauth/callback?code=good-code&state={oauth_state}"
))
.send()
.await
.unwrap();
assert_eq!(callback.status(), 303, "redirects back to the panel");
let directory: Value = client
.get(format!("{base}/api/apps?clawId={claw_id}"))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let notion = directory
.as_array()
.unwrap()
.iter()
.find(|a| a["id"] == "notion")
.unwrap();
assert_eq!(notion["connected"], true);
// The raw token never landed in Postgres outside the encrypted store.
let leaked = sqlx::query_scalar::<_, i64>(
"SELECT count(*) FROM secrets WHERE convert_from(ciphertext, 'UTF8') LIKE '%idp-access-token%'",
)
.fetch_one(&pool)
.await
.unwrap_or(0);
assert_eq!(leaked, 0, "token must be encrypted at rest");
// Replayed state is refused.
let replay = client
.get(format!(
"{base}/api/apps/oauth/callback?code=good-code&state={oauth_state}"
))
.send()
.await
.unwrap();
assert_eq!(replay.status(), 404);
// Forged state is refused.
let forged = client
.get(format!(
"{base}/api/apps/oauth/callback?code=good-code&state=forged"
))
.send()
.await
.unwrap();
assert_eq!(forged.status(), 404);
// A bad code fails the exchange (fresh state, IdP rejects).
let start2: Value = client
.post(format!("{base}/api/apps/oauth/start"))
.bearer_auth(&token)
.json(&json!({"clawId": claw_id, "provider": "linear"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let bad = client
.get(format!(
"{base}/api/apps/oauth/callback?code=bad-code&state={}",
start2["state"].as_str().unwrap()
))
.send()
.await
.unwrap();
assert_eq!(bad.status(), 500);
}
#[tokio::test]
async fn mcp_oauth_uses_the_custom_issuer() {
let pool = tc_testkit::test_pool().await;
let issuer = spawn_idp().await;
let socket = spawn_broker(pool.clone()).await;
let runtime = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml("").unwrap()),
RuntimeConfig::basic("scripted", 1024),
);
// No default issuer configured: only mcp_oauth with an explicit issuer
// can start.
let oauth = OAuthConfig {
issuer_url: None,
client_id: Some("teamclaw".into()),
client_secret: Some("tc-secret".into()),
redirect_base: Some("http://127.0.0.1:9".into()),
};
let app = tc_api::router(
AppState::new(pool.clone(), runtime)
.with_broker(socket)
.with_oauth(oauth),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base = format!("http://{addr}");
let client = reqwest::Client::new();
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
tc_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
tc_db::repo::users::insert(&pool, &owner).await.unwrap();
AuthService::new(pool.clone())
.set_password(owner.id, "pw")
.await
.unwrap();
let token = client
.post(format!("{base}/api/auth/login"))
.json(&json!({"email": owner.email, "password": "pw"}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_owned();
let claw: Value = client
.post(format!("{base}/api/claws"))
.bearer_auth(&token)
.json(&json!({"name": "Scout", "job_title": "Analyst"}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let claw_id = claw["id"].as_str().unwrap();
// Plain oauth refuses (no configured issuer)...
let refused = client
.post(format!("{base}/api/apps/oauth/start"))
.bearer_auth(&token)
.json(&json!({"clawId": claw_id, "provider": "notion"}))
.send()
.await
.unwrap();
assert_eq!(refused.status(), 409);
// ...while mcp_oauth with the custom issuer proceeds.
let start: Value = client
.post(format!("{base}/api/apps/oauth/start"))
.bearer_auth(&token)
.json(&json!({
"clawId": claw_id,
"provider": "my-mcp-server",
"authType": "mcp_oauth",
"issuerUrl": issuer,
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(start["authorize_url"]
.as_str()
.unwrap()
.starts_with(&format!("{issuer}/auth?")));
}
+12
View File
@@ -109,6 +109,16 @@ impl Default for SlackConfig {
} }
} }
#[derive(Debug, Clone, Default, Deserialize)]
pub struct OAuthConfig {
/// Default identity provider for directory-app OAuth connects.
pub issuer_url: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
/// Public base URL of this server (builds the redirect_uri).
pub redirect_base: Option<String>,
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct AppConfig { pub struct AppConfig {
pub deploy_target: DeployTarget, pub deploy_target: DeployTarget,
@@ -122,6 +132,8 @@ pub struct AppConfig {
pub broker: BrokerConfig, pub broker: BrokerConfig,
#[serde(default)] #[serde(default)]
pub slack: SlackConfig, pub slack: SlackConfig,
#[serde(default)]
pub oauth: OAuthConfig,
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, type FormEvent } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
@@ -10,13 +10,34 @@ interface DirectoryApp {
name: string; name: string;
description: string; description: string;
category: string; category: string;
connected: boolean;
} }
/** The connect directory (§7.8). The directory is live; per-app OAuth /** The connect directory (§7.8): inline API-key connects; OAuth/MCP run
* connect flows arrive in P4. */ * the authorization-code flow server-side. */
export default function AddAppsApp({ agent }: { agent: Agent }) { export default function AddAppsApp({ agent }: { agent: Agent }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const { data, loading } = useFetchJson<DirectoryApp[]>("/api/apps"); const [connecting, setConnecting] = useState<string | null>(null);
const { data, loading, refresh } = useFetchJson<DirectoryApp[]>(
`/api/apps?clawId=${agent.id}`,
);
async function connectKeys(event: FormEvent<HTMLFormElement>, appId: string) {
event.preventDefault();
const form = new FormData(event.currentTarget);
await fetch("/api/apps/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clawId: agent.id,
provider: appId,
authType: "keys",
secret: form.get("secret"),
}),
});
setConnecting(null);
refresh();
}
const apps = (data ?? []).filter((app) => const apps = (data ?? []).filter((app) =>
`${app.name} ${app.category}`.toLowerCase().includes(query.toLowerCase()), `${app.name} ${app.category}`.toLowerCase().includes(query.toLowerCase()),
); );
@@ -36,23 +57,60 @@ export default function AddAppsApp({ agent }: { agent: Agent }) {
) : ( ) : (
<ul aria-label="App directory"> <ul aria-label="App directory">
{apps.map((app) => ( {apps.map((app) => (
<li key={app.id} className="flex items-start gap-2 px-2 py-2"> <li key={app.id} className="px-2 py-2">
<div className="min-w-0 flex-1"> <div className="flex items-start gap-2">
<p className="text-sm">{app.name}</p> <div className="min-w-0 flex-1">
<p className="truncate text-xs text-muted-foreground"> <p className="text-sm">{app.name}</p>
{app.description} <p className="truncate text-xs text-muted-foreground">
</p> {app.description}
</p>
</div>
{app.connected ? (
<span className="rounded-(--radius-button) border border-border px-2 py-0.5 text-xxs text-muted-foreground">
✓ Connected
</span>
) : (
<button
type="button"
aria-label={`Connect ${app.name}`}
onClick={() =>
setConnecting(connecting === app.id ? null : app.id)
}
className="rounded-(--radius-button) border border-border px-2 text-sm text-muted-foreground hover:border-accent hover:text-foreground"
>
+
</button>
)}
</div> </div>
<span className="rounded-(--radius-button) border border-border px-2 py-0.5 text-xxs text-muted-foreground"> {connecting === app.id && (
{app.category} <form
</span> onSubmit={(e) => connectKeys(e, app.id)}
className="flex gap-2 pt-2"
>
<input
name="secret"
type="password"
required
aria-label={`${app.name} API key`}
placeholder="API key / token"
className="min-w-0 flex-1 rounded-(--radius) border border-input bg-subtle px-2 py-1 text-xs outline-none focus:border-accent"
/>
<button
type="submit"
className="rounded-(--radius-button) bg-accent px-3 py-1 text-xs font-medium text-background hover:bg-coral-light"
>
Connect
</button>
</form>
)}
</li> </li>
))} ))}
</ul> </ul>
)} )}
<p className="px-2 pt-3 text-xxs text-muted-foreground"> <p className="px-2 pt-3 text-xxs text-muted-foreground">
Connecting apps for {agent.name} (OAuth, keys, MCP) arrives with Connect with an API key here (OAuth and MCP servers connect through
integrations (P4). your identity provider). Credentials go straight to the secret
broker.
</p> </p>
</div> </div>
); );
+9 -1
View File
@@ -117,7 +117,7 @@ test("every panel app is reachable and the deep link cold-loads", async ({
["Browser", /No active browsing session/], ["Browser", /No active browsing session/],
["Slack", /Bring this claw into Slack/], ["Slack", /Bring this claw into Slack/],
["Claw Chat", /No claw-to-claw conversations yet/], ["Claw Chat", /No claw-to-claw conversations yet/],
["Add", /OAuth, keys, MCP/], ["Add", /Connect with an API key/],
["Skills", /Scout's Skills|Installed skills/], ["Skills", /Scout's Skills|Installed skills/],
]; ];
for (const [tile, marker] of stops) { for (const [tile, marker] of stops) {
@@ -125,6 +125,14 @@ test("every panel app is reachable and the deep link cold-loads", async ({
await panel.getByRole("button", { name: tile, exact: true }).click(); await panel.getByRole("button", { name: tile, exact: true }).click();
await expect(panel.getByText(marker).first()).toBeVisible(); await expect(panel.getByText(marker).first()).toBeVisible();
} }
// Inline keys connect from the directory (P4): Notion via API key.
await panel.getByRole("button", { name: "Computer home" }).click();
await panel.getByRole("button", { name: "Add", exact: true }).click();
await panel.getByRole("button", { name: "Connect Notion" }).click();
await panel.getByLabel("Notion API key").fill("secret_notion_key");
await panel.getByRole("button", { name: "Connect", exact: true }).click();
await expect(panel.getByText("✓ Connected")).toBeVisible();
}); });
test("the skill library page lists skills and installs onto a claw", async ({ test("the skill library page lists skills and installs onto a claw", async ({
+15
View File
@@ -0,0 +1,15 @@
-- Pending OAuth authorization-code flows (§7.8 / §10). One-time states:
-- consumed (deleted) on callback, swept on expiry.
CREATE TABLE oauth_states (
state TEXT PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id),
user_id UUID NOT NULL REFERENCES users (id),
agent_id UUID NOT NULL REFERENCES agents (id),
provider TEXT NOT NULL,
auth_type TEXT NOT NULL CHECK (auth_type IN ('oauth', 'mcp_oauth')),
-- Issuer for custom MCP servers; the configured default otherwise.
issuer_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);