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:
co-authored by
Claude Fable 5
parent
6dbdd20ee0
commit
91327e3618
+20
@@ -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"
|
||||||
|
}
|
||||||
+52
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
)))
|
||||||
|
}
|
||||||
@@ -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?")));
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
@@ -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="flex items-start gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm">{app.name}</p>
|
<p className="text-sm">{app.name}</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
{app.description}
|
{app.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{app.connected ? (
|
||||||
<span className="rounded-(--radius-button) border border-border px-2 py-0.5 text-xxs text-muted-foreground">
|
<span className="rounded-(--radius-button) border border-border px-2 py-0.5 text-xxs text-muted-foreground">
|
||||||
{app.category}
|
✓ Connected
|
||||||
</span>
|
</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>
|
||||||
|
{connecting === app.id && (
|
||||||
|
<form
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 ({
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user