use crate::lenient::{array, boolean, get, int, string, uint};
use base64::Engine;
use rsa::pkcs1::DecodeRsaPublicKey;
use rsa::pkcs1v15::{Signature, VerifyingKey};
use rsa::pkcs8::DecodePublicKey;
use rsa::signature::Verifier;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Headers {
pub message_id: String,
pub timestamp: String,
pub signature: String,
pub event_type: String,
pub version: String,
pub subscription_id: Option<String>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum WebhookError {
#[error("missing header {0}")]
MissingHeader(&'static str),
#[error("unreadable public key")]
BadKey,
#[error("malformed signature")]
MalformedSignature,
#[error("signature does not match")]
Mismatch,
}
impl Headers {
pub fn from_pairs<'a>(pairs: impl IntoIterator<Item = (&'a str, &'a str)>) -> Result<Self, WebhookError> {
let mut found: [Option<String>; 6] = Default::default();
const NAMES: [&str; 6] = [
"kick-event-message-id",
"kick-event-message-timestamp",
"kick-event-signature",
"kick-event-type",
"kick-event-version",
"kick-event-subscription-id",
];
for (name, value) in pairs {
if let Some(i) = NAMES.iter().position(|n| n.eq_ignore_ascii_case(name)) {
found[i] = Some(value.to_owned());
}
}
let [id, ts, sig, ty, ver, sub] = found;
Ok(Headers {
message_id: id.ok_or(WebhookError::MissingHeader(NAMES[0]))?,
timestamp: ts.ok_or(WebhookError::MissingHeader(NAMES[1]))?,
signature: sig.ok_or(WebhookError::MissingHeader(NAMES[2]))?,
event_type: ty.ok_or(WebhookError::MissingHeader(NAMES[3]))?,
version: ver.ok_or(WebhookError::MissingHeader(NAMES[4]))?,
subscription_id: sub,
})
}
}
pub fn verify(public_key_pem: &str, headers: &Headers, body: &[u8]) -> Result<(), WebhookError> {
let pem = public_key_pem.trim();
let key = rsa::RsaPublicKey::from_public_key_pem(pem)
.or_else(|_| rsa::RsaPublicKey::from_pkcs1_pem(pem))
.map_err(|_| WebhookError::BadKey)?;
let raw = base64::engine::general_purpose::STANDARD
.decode(headers.signature.trim())
.map_err(|_| WebhookError::MalformedSignature)?;
let signature = Signature::try_from(raw.as_slice()).map_err(|_| WebhookError::MalformedSignature)?;
let mut signed = Vec::with_capacity(headers.message_id.len() + headers.timestamp.len() + body.len() + 2);
signed.extend_from_slice(headers.message_id.as_bytes());
signed.push(b'.');
signed.extend_from_slice(headers.timestamp.as_bytes());
signed.push(b'.');
signed.extend_from_slice(body);
VerifyingKey::<sha2::Sha256>::new(key).verify(&signed, &signature).map_err(|_| WebhookError::Mismatch)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Person {
pub user_id: Option<u64>,
pub username: Option<String>,
pub channel_slug: Option<String>,
pub is_verified: Option<bool>,
pub is_anonymous: Option<bool>,
pub profile_picture: Option<String>,
}
impl Person {
fn from(v: Option<&Value>) -> Option<Self> {
let v = v?;
Some(Person {
user_id: uint(v, "user_id"),
username: string(v, "username"),
channel_slug: string(v, "channel_slug"),
is_verified: boolean(v, "is_verified"),
is_anonymous: boolean(v, "is_anonymous"),
profile_picture: string(v, "profile_picture"),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Category {
pub id: Option<u64>,
pub name: Option<String>,
pub thumbnail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum WebhookEvent {
LivestreamStatus {
broadcaster: Option<Person>,
is_live: bool,
title: Option<String>,
started_at: Option<String>,
ended_at: Option<String>,
},
LivestreamMetadata {
broadcaster: Option<Person>,
title: Option<String>,
language: Option<String>,
has_mature_content: Option<bool>,
category: Option<Category>,
},
Followed {
broadcaster: Option<Person>,
follower: Option<Person>,
},
Subscription {
renewal: bool,
broadcaster: Option<Person>,
subscriber: Option<Person>,
duration: Option<i64>,
created_at: Option<String>,
expires_at: Option<String>,
},
SubscriptionGifts {
broadcaster: Option<Person>,
gifter: Option<Person>,
giftees: Vec<Person>,
created_at: Option<String>,
expires_at: Option<String>,
},
KicksGifted {
broadcaster: Option<Person>,
sender: Option<Person>,
amount: Option<i64>,
name: Option<String>,
kind: Option<String>,
tier: Option<String>,
message: Option<String>,
pinned_time_seconds: Option<i64>,
created_at: Option<String>,
},
Other {
event_type: String,
body: Value,
},
}
pub fn parse(event_type: &str, body: &str) -> Result<WebhookEvent, serde_json::Error> {
let v: Value = serde_json::from_str(body)?;
let broadcaster = || Person::from(get(&v, "broadcaster"));
Ok(match event_type {
"livestream.status.updated" => WebhookEvent::LivestreamStatus {
broadcaster: broadcaster(),
is_live: boolean(&v, "is_live").unwrap_or(false),
title: string(&v, "title"),
started_at: string(&v, "started_at"),
ended_at: string(&v, "ended_at"),
},
"livestream.metadata.updated" => {
let m = get(&v, "metadata").cloned().unwrap_or_default();
let c = get(&m, "category").or_else(|| get(&m, "Category"));
WebhookEvent::LivestreamMetadata {
broadcaster: broadcaster(),
title: string(&m, "title"),
language: string(&m, "language"),
has_mature_content: boolean(&m, "has_mature_content"),
category: c.map(|c| Category {
id: uint(c, "id"),
name: string(c, "name"),
thumbnail: string(c, "thumbnail"),
}),
}
}
"channel.followed" => {
WebhookEvent::Followed { broadcaster: broadcaster(), follower: Person::from(get(&v, "follower")) }
}
"channel.subscription.new" | "channel.subscription.renewal" => WebhookEvent::Subscription {
renewal: event_type.ends_with("renewal"),
broadcaster: broadcaster(),
subscriber: Person::from(get(&v, "subscriber")),
duration: int(&v, "duration"),
created_at: string(&v, "created_at"),
expires_at: string(&v, "expires_at"),
},
"channel.subscription.gifts" => WebhookEvent::SubscriptionGifts {
broadcaster: broadcaster(),
gifter: Person::from(get(&v, "gifter")),
giftees: array(&v, "giftees").iter().filter_map(|g| Person::from(Some(g))).collect(),
created_at: string(&v, "created_at"),
expires_at: string(&v, "expires_at"),
},
"kicks.gifted" => {
let g = get(&v, "gift").cloned().unwrap_or_default();
WebhookEvent::KicksGifted {
broadcaster: broadcaster(),
sender: Person::from(get(&v, "sender")),
amount: int(&g, "amount"),
name: string(&g, "name"),
kind: string(&g, "type"),
tier: string(&g, "tier"),
message: string(&g, "message"),
pinned_time_seconds: int(&g, "pinned_time_seconds"),
created_at: string(&v, "created_at"),
}
}
other => WebhookEvent::Other { event_type: other.to_owned(), body: v },
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn headers_in_any_case_and_missing_ones_named() {
let pairs = [
("Kick-Event-Message-Id", "01M"),
("kick-event-message-timestamp", "2026-09-24T02:35:42Z"),
("KICK-EVENT-SIGNATURE", "c2ln"),
("kick-event-type", "channel.followed"),
("kick-event-version", "1"),
];
let h = Headers::from_pairs(pairs).unwrap();
assert_eq!(h.event_type, "channel.followed");
assert_eq!(h.subscription_id, None);
assert_eq!(
Headers::from_pairs(pairs[1..].iter().copied()),
Err(WebhookError::MissingHeader("kick-event-message-id"))
);
}
}