Skip to content

Commit 8973d1b

Browse files
henrypark133claude
andauthored
fix: use gateway owner_id for relay OAuth nonce storage (#2473)
* fix: use gateway owner_id for relay OAuth nonce storage The relay OAuth nonce was stored under the authenticated user's ID (a DB user UUID) but the callback handler looked it up under state.owner_id (the gateway owner, typically "default"). This mismatch caused the nonce lookup to silently fail, returning "Invalid or expired authorization" on every Slack OAuth callback. Use self.user_id (which holds config.owner_id) in auth_channel_relay for nonce storage so both sides use the same scope. Also adds tracing to the callback handler's get_decrypted error path to make future auth failures diagnosable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add test for nonce user scope mismatch Verifies that a nonce stored under a DB user UUID (different from the gateway owner_id) is not found by the callback handler, reproducing the bug that caused "Invalid or expired authorization" on hosted instances. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review comments - Also delete legacy caller-scoped nonce on upgrade (Copilot) - Include redacted state param in tracing log (Gemini) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2dc78b2 commit 8973d1b

2 files changed

Lines changed: 73 additions & 3 deletions

File tree

src/channels/web/server.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2119,7 +2119,14 @@ async fn slack_relay_oauth_callback_handler(
21192119
.await
21202120
{
21212121
Ok(secret) => secret.expose().to_string(),
2122-
Err(_) => {
2122+
Err(e) => {
2123+
tracing::warn!(
2124+
owner_id = %state.owner_id,
2125+
state_key = %state_key,
2126+
state = %redact_oauth_state_for_logs(&state_param),
2127+
error = %e,
2128+
"relay OAuth callback: failed to retrieve stored nonce"
2129+
);
21232130
return axum::response::Html(
21242131
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
21252132
<h2>Error</h2><p>Invalid or expired authorization.</p></body></html>"
@@ -6644,6 +6651,59 @@ mod tests {
66446651
assert!(!exists, "CSRF nonce should be deleted after use");
66456652
}
66466653

6654+
#[tokio::test]
6655+
async fn test_relay_oauth_callback_nonce_under_different_user_fails() {
6656+
// why: In hosted mode, the DB user's UUID differs from the gateway
6657+
// owner_id. If the nonce is stored under the DB user's scope,
6658+
// the callback handler (which uses owner_id) cannot find it.
6659+
use axum::body::Body;
6660+
use tower::ServiceExt;
6661+
6662+
let secrets = test_secrets_store();
6663+
let nonce = "nonce-stored-under-wrong-user";
6664+
6665+
// given: nonce stored under a DB user UUID, NOT the gateway owner ("test")
6666+
secrets
6667+
.create(
6668+
"b50a4a66-ba1b-439c-907b-cc6b371871b0",
6669+
crate::secrets::CreateSecretParams::new(
6670+
format!("relay:{}:oauth_state", DEFAULT_RELAY_NAME),
6671+
nonce,
6672+
),
6673+
)
6674+
.await
6675+
.expect("store nonce");
6676+
6677+
// ext_mgr.user_id = "test", gateway owner_id = "test"
6678+
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets);
6679+
let state = test_gateway_state(Some(ext_mgr));
6680+
let app = test_relay_oauth_router(state);
6681+
6682+
// when: callback arrives with the correct nonce value
6683+
let req = axum::http::Request::builder()
6684+
.uri(format!(
6685+
"/oauth/slack/callback?team_id=T123&provider=slack&state={}",
6686+
nonce
6687+
))
6688+
.body(Body::empty())
6689+
.expect("request");
6690+
6691+
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
6692+
.await
6693+
.expect("response");
6694+
6695+
// then: fails because nonce is under a different user scope
6696+
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
6697+
.await
6698+
.expect("body");
6699+
let html = String::from_utf8_lossy(&body);
6700+
assert!(
6701+
html.contains("Invalid or expired authorization"),
6702+
"Nonce stored under wrong user scope should fail lookup, got: {}",
6703+
&html[..html.len().min(300)]
6704+
);
6705+
}
6706+
66476707
#[test]
66486708
fn test_is_local_origin_localhost() {
66496709
assert!(is_local_origin("http://localhost:3001"));

src/extensions/manager.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6072,10 +6072,20 @@ impl ExtensionManager {
60726072
// state and appends it to the post-OAuth redirect URL.
60736073
let state_nonce = uuid::Uuid::new_v4().to_string();
60746074
let state_key = format!("relay:{}:oauth_state", name);
6075-
// Delete any stale nonce before storing the new one
6075+
// Delete any stale nonce before storing the new one.
6076+
// Use self.user_id (the gateway owner) — NOT the caller's user_id —
6077+
// because the OAuth callback handler looks up the nonce under
6078+
// state.owner_id which matches self.user_id.
6079+
//
6080+
// Also best-effort delete any legacy caller-scoped entry so older
6081+
// per-user nonces don't remain in the secrets table after upgrading.
60766082
let _ = self.secrets.delete(user_id, &state_key).await;
6083+
let _ = self.secrets.delete(&self.user_id, &state_key).await;
60776084
self.secrets
6078-
.create(user_id, CreateSecretParams::new(&state_key, &state_nonce))
6085+
.create(
6086+
&self.user_id,
6087+
CreateSecretParams::new(&state_key, &state_nonce),
6088+
)
60796089
.await
60806090
.map_err(|e| {
60816091
tracing::warn!(

0 commit comments

Comments
 (0)