Skip to content

Commit b30864e

Browse files
feat(api-integration): add Nango webhook handling (#3716)
* feat(api-integration): add webhook handling for Nango auth events Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * refactor: make nango_webhook_secret non-optional Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * refactor(nango): add verify_webhook_signature and use it from api-integration Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * chore: cleanup after moving webhook verification Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * fix(api-integration): require webhook secret in IntegrationConfig::new; adapt webhook signature verification to nango API Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * fix(api-integration): parse NangoAuthWebhook and commit lockfile Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * style: dprint format webhook route Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * style(permissions): dprint fmt Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: yujonglee <yujonglee.dev@gmail.com>
1 parent c90638e commit b30864e

File tree

11 files changed

+101
-19
lines changed

11 files changed

+101
-19
lines changed

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,9 @@ objc2-event-kit = "0.3"
297297
objc2-foundation = "0.3"
298298
objc2-user-notifications = "0.3"
299299

300+
hmac = "0.12"
301+
sha2 = "0.10"
302+
300303
tokenizers = "0.21.4"
301304
whichlang = "0.1"
302305

crates/api-integration/src/config.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@ use std::sync::Arc;
44
pub struct IntegrationConfig {
55
pub nango_api_base: String,
66
pub nango_api_key: String,
7+
pub nango_webhook_secret: String,
78
pub auth: Option<Arc<hypr_supabase_auth::SupabaseAuth>>,
89
}
910

1011
impl IntegrationConfig {
11-
pub fn new(nango_api_base: impl Into<String>, nango_api_key: impl Into<String>) -> Self {
12+
pub fn new(
13+
nango_api_base: impl Into<String>,
14+
nango_api_key: impl Into<String>,
15+
nango_webhook_secret: impl Into<String>,
16+
) -> Self {
1217
Self {
1318
nango_api_base: nango_api_base.into(),
1419
nango_api_key: nango_api_key.into(),
20+
nango_webhook_secret: nango_webhook_secret.into(),
1521
auth: None,
1622
}
1723
}

crates/api-integration/src/env.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ use serde::Deserialize;
44
pub struct Env {
55
pub nango_api_base: String,
66
pub nango_api_key: String,
7+
pub nango_webhook_secret: String,
78
}

crates/api-integration/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ mod state;
77
pub use config::IntegrationConfig;
88
pub use env::Env;
99
pub use error::{IntegrationError, Result};
10-
pub use routes::{openapi, router};
10+
pub use routes::{WebhookResponse, openapi, router};
1111
pub use state::AppState;

crates/api-integration/src/routes/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
mod connect;
2+
mod webhook;
23

34
use axum::{Router, routing::post};
45
use utoipa::OpenApi;
56

67
use crate::state::AppState;
78

89
pub use connect::ConnectSessionResponse;
10+
pub use webhook::WebhookResponse;
911

1012
#[derive(OpenApi)]
1113
#[openapi(
1214
paths(
1315
connect::create_connect_session,
16+
webhook::nango_webhook,
1417
),
1518
components(
1619
schemas(
1720
ConnectSessionResponse,
21+
WebhookResponse,
1822
)
1923
),
2024
tags(
@@ -30,5 +34,6 @@ pub fn openapi() -> utoipa::openapi::OpenApi {
3034
pub fn router(state: AppState) -> Router {
3135
Router::new()
3236
.route("/connect-session", post(connect::create_connect_session))
37+
.route("/webhook", post(webhook::nango_webhook))
3338
.with_state(state)
3439
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
use axum::{Json, extract::State, http::HeaderMap};
2+
use serde::Serialize;
3+
use utoipa::ToSchema;
4+
5+
use crate::error::{IntegrationError, Result};
6+
use crate::state::AppState;
7+
8+
#[derive(Debug, Serialize, ToSchema)]
9+
pub struct WebhookResponse {
10+
pub status: String,
11+
}
12+
13+
#[utoipa::path(
14+
post,
15+
path = "/webhook",
16+
responses(
17+
(status = 200, description = "Webhook processed", body = WebhookResponse),
18+
(status = 401, description = "Invalid signature"),
19+
(status = 400, description = "Bad request"),
20+
),
21+
tag = "integration",
22+
)]
23+
pub async fn nango_webhook(
24+
State(state): State<AppState>,
25+
headers: HeaderMap,
26+
body: String,
27+
) -> Result<Json<WebhookResponse>> {
28+
let signature = headers
29+
.get("x-nango-hmac-sha256")
30+
.and_then(|h| h.to_str().ok())
31+
.ok_or_else(|| IntegrationError::Auth("Missing X-Nango-Hmac-Sha256 header".to_string()))?;
32+
33+
let valid = hypr_nango::verify_webhook_signature(
34+
&state.config.nango_webhook_secret,
35+
body.as_bytes(),
36+
signature,
37+
);
38+
if !valid {
39+
return Err(IntegrationError::Auth(
40+
"Invalid webhook signature".to_string(),
41+
));
42+
}
43+
44+
let payload: hypr_nango::NangoAuthWebhook =
45+
serde_json::from_str(&body).map_err(|e| IntegrationError::BadRequest(e.to_string()))?;
46+
47+
tracing::info!(
48+
webhook_type = %payload.r#type,
49+
operation = %payload.operation,
50+
connection_id = %payload.connection_id,
51+
end_user_id = %payload.end_user.end_user_id,
52+
"nango webhook received"
53+
);
54+
55+
Ok(Json(WebhookResponse {
56+
status: "ok".to_string(),
57+
}))
58+
}

crates/nango/Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ edition = "2024"
55

66
[dependencies]
77
hex = "0.4"
8-
hmac = "0.12"
98
reqwest = { workspace = true, features = ["json"] }
109
schemars = { workspace = true }
1110
serde = { workspace = true, features = ["derive"] }
1211
serde_json = { workspace = true }
13-
sha2 = "0.10"
1412
specta = { workspace = true, features = ["derive", "serde_json"] }
1513
strum = { workspace = true, features = ["derive"] }
1614
thiserror = { workspace = true }
1715
url = { workspace = true }
1816

17+
hmac = { workspace = true }
18+
sha2 = { workspace = true }
19+
1920
[dev-dependencies]
2021
tokio = { workspace = true, features = ["rt", "macros"] }

crates/nango/src/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ pub enum Error {
1818
InvalidApiBase,
1919
#[error("invalid url: cannot modify path segments")]
2020
InvalidUrl,
21+
#[error("webhook signature error: {0}")]
22+
WebhookSignature(String),
2123
}
2224

2325
impl Serialize for Error {

plugins/permissions/src/ext.rs

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ macro_rules! check {
1919
}};
2020
}
2121

22-
2322
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, specta::Type)]
2423
#[serde(rename_all = "camelCase")]
2524
pub enum Permission {
@@ -117,7 +116,9 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager<R>> Permissions<'a, R, M> {
117116
#[cfg(target_os = "macos")]
118117
{
119118
std::process::Command::new("open")
120-
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")
119+
.arg(
120+
"x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
121+
)
121122
.spawn()?
122123
.wait()?;
123124
}
@@ -129,7 +130,9 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager<R>> Permissions<'a, R, M> {
129130
#[cfg(target_os = "macos")]
130131
{
131132
std::process::Command::new("open")
132-
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
133+
.arg(
134+
"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
135+
)
133136
.spawn()?
134137
.wait()?;
135138
}
@@ -139,10 +142,9 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager<R>> Permissions<'a, R, M> {
139142

140143
async fn check_calendar(&self) -> Result<PermissionStatus, crate::Error> {
141144
#[cfg(target_os = "macos")]
142-
return check!(
143-
"calendar",
144-
unsafe { EKEventStore::authorizationStatusForEntityType(EKEntityType::Event) }
145-
);
145+
return check!("calendar", unsafe {
146+
EKEventStore::authorizationStatusForEntityType(EKEntityType::Event)
147+
});
146148

147149
#[cfg(not(target_os = "macos"))]
148150
{
@@ -152,10 +154,9 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager<R>> Permissions<'a, R, M> {
152154

153155
async fn check_contacts(&self) -> Result<PermissionStatus, crate::Error> {
154156
#[cfg(target_os = "macos")]
155-
return check!(
156-
"contacts",
157-
unsafe { CNContactStore::authorizationStatusForEntityType(CNEntityType::Contacts) }
158-
);
157+
return check!("contacts", unsafe {
158+
CNContactStore::authorizationStatusForEntityType(CNEntityType::Contacts)
159+
});
159160

160161
#[cfg(not(target_os = "macos"))]
161162
{
@@ -202,7 +203,10 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager<R>> Permissions<'a, R, M> {
202203

203204
async fn check_accessibility(&self) -> Result<PermissionStatus, crate::Error> {
204205
#[cfg(target_os = "macos")]
205-
return check!("accessibility", macos_accessibility_client::accessibility::application_is_trusted());
206+
return check!(
207+
"accessibility",
208+
macos_accessibility_client::accessibility::application_is_trusted()
209+
);
206210

207211
#[cfg(not(target_os = "macos"))]
208212
{
@@ -379,4 +383,3 @@ impl<R: tauri::Runtime, T: tauri::Manager<R>> PermissionsPluginExt<R> for T {
379383
}
380384
}
381385
}
382-

0 commit comments

Comments
 (0)