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
+3 -1
View File
@@ -91,7 +91,9 @@ async fn run() -> Result<(), String> {
.spawn(std::time::Duration::from_secs(5));
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() {
app = app.merge(e2e::slack_sink_router());