Rebrand: TeamClaw -> Clawmates (clawmates.work)
Full-depth rename per the approved plan; the 'claw' product vocabulary (claws, /claws routes, clawId, Claw Chat) stays — it is now the brand. - Display brand: Clawmates (manifest, titles, hero, login/rail logo 'clawmates'); default host app.clawmates.work; registry ghcr.io/clawmates - Crates tc-* -> cm-* (16 crates + all imports); binaries clawmates-server/broker/bundler; images clawmates/*; env prefix CLAWMATES_* (+ CM_TEST_DATABASE_URL / CM_LIVE_LLM); config clawmates.toml; helm chart deploy/helm/clawmates with clawmates-* resources; db names clawmates*; sockets /run/clawmates; cookie cm_session; kind cluster clawmates-test; seccomp node profile clawmates-agent-profile.json - All 9 Playwright brand assertions updated in lockstep; historical spec document left untouched as the only remaining 'TeamClaw' - Local env migrated: dev pg clawmates-dev-pg/clawmates_dev, shared test server clawmates-test-pg, kind cluster recreated with image + profile, compose images rebuilt under clawmates/* Verified end to end: 161 Rust + 68 frontend tests, 29 Playwright journeys, 4 live kind tests, helm/install/LOC/placeholder gates, and the clean-room install rehearsal serving the clawmates login page from a signed bundle of the rebuilt images. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8046853feb
commit
add4f79fed
@@ -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 cm_db::repo::audit::Actor;
|
||||
use cm_domain::{AgentId, UserId, WorkspaceId};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
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 = cm_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 = cm_db::repo::connections::insert(
|
||||
&state.pool,
|
||||
workspace_id,
|
||||
Some(AgentId::from(pending.agent_id)),
|
||||
&pending.provider,
|
||||
&pending.auth_type,
|
||||
secret_ref,
|
||||
)
|
||||
.await?;
|
||||
cm_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
|
||||
)))
|
||||
}
|
||||
Reference in New Issue
Block a user