Skip to content

Instantly share code, notes, and snippets.

@FelipeBudinich
Last active March 10, 2025 12:02
Show Gist options
  • Save FelipeBudinich/1f59f24afc20371ffabdd3d8edda47ea to your computer and use it in GitHub Desktop.
Save FelipeBudinich/1f59f24afc20371ffabdd3d8edda47ea to your computer and use it in GitHub Desktop.
Barebones KICK OAuth and Webhooks implementation
const express = require('express');
const crypto = require('crypto');
const axios = require('axios');
require('dotenv/config');
const app = express();
// Capture the raw body for signature verification.
app.use(express.json({
verify: (req, res, buf, encoding) => {
req.rawBody = buf.toString(encoding || 'utf8');
}
}));
// Environment variables for OAuth
const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.CLIENT_SECRET;
// Hardcoded Kick public key (PEM formatted)
const KICK_PUBLIC_KEY = `
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAq/+l1WnlRrGSolDMA+A8
6rAhMbQGmQ2SapVcGM3zq8ANXjnhDWocMqfWcTd95btDydITa10kDvHzw9WQOqp2
MZI7ZyrfzJuz5nhTPCiJwTwnEtWft7nV14BYRDHvlfqPUaZ+1KR4OCaO/wWIk/rQ
L/TjY0M70gse8rlBkbo2a8rKhu69RQTRsoaf4DVhDPEeSeI5jVrRDGAMGL3cGuyY
6CLKGdjVEM78g3JfYOvDU/RvfqD7L89TZ3iN94jrmWdGz34JNlEI5hqK8dd7C5EF
BEbZ5jgB8s8ReQV8H+MkuffjdAj3ajDDX3DOJMIut1lBrUVD1AaSrGCKHooWoL2e
twIDAQAB
-----END PUBLIC KEY-----
`;
let kickAccessToken = null;
/**
* Constructs the redirect URI dynamically based on the incoming request.
*/
function getRedirectUri(req) {
return `${req.protocol}://${req.get('host')}/callback`;
}
/**
* Generates a code verifier using random bytes.
*/
function generateCodeVerifier() {
const buffer = crypto.randomBytes(32);
return buffer.toString('base64url');
}
/**
* Generates a PKCE code challenge from the verifier using SHA-256.
*/
function generateCodeChallenge(verifier) {
const hash = crypto.createHash('sha256').update(verifier).digest();
return hash.toString('base64url');
}
/**
* Initiates the OAuth process by redirecting the user to Kick's authorization endpoint.
*/
function oauthLogin(req, res) {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
// Embed the codeVerifier in the state parameter as base64-encoded JSON.
const state = Buffer.from(JSON.stringify({ codeVerifier })).toString('base64');
const redirectUri = getRedirectUri(req);
const params = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
redirect_uri: redirectUri,
scope: 'chat:read events:subscribe', // Adjust scopes as needed
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
const authUrl = `https://id.kick.com/oauth/authorize?${params.toString()}`;
console.log('Redirecting to Kick OAuth URL:', authUrl);
res.redirect(authUrl);
}
/**
* Handles the OAuth callback by exchanging the authorization code for an access token,
* and then subscribes to the chat event using webhooks.
*/
async function oauthCallback(req, res) {
const { code, state } = req.query;
const redirectUri = getRedirectUri(req);
if (!code) {
return res.status(400).json({ error: "Missing authorization code" });
}
try {
// Decode the state to retrieve the original codeVerifier.
const stateDecoded = Buffer.from(state, 'base64').toString();
const { codeVerifier } = JSON.parse(stateDecoded);
const tokenParams = new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uri: redirectUri,
code,
code_verifier: codeVerifier
});
const tokenResponse = await fetch('https://id.kick.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: tokenParams
});
const tokenData = await tokenResponse.json();
kickAccessToken = tokenData.access_token;
console.log('Kick OAuth successful, access token acquired:', kickAccessToken);
// Subscribe to the "chat.message.sent" event using webhook
await subscribeToEvents();
res.send('Kick OAuth successful. Event subscription has been initiated. The server is now ready to receive webhooks.');
} catch (error) {
console.error('Error during token exchange:', error.message);
res.status(500).send('OAuth token exchange failed');
}
}
/**
* Subscribes to the "chat.message.sent" event using the webhook method.
*/
async function subscribeToEvents() {
try {
const payload = {
method: "webhook",
events: [
{
name: "chat.message.sent",
version: 1
}
]
};
const response = await axios.post('https://api.kick.com/public/v1/events/subscriptions', payload, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${kickAccessToken}`
}
});
console.log('Event subscription response:', response.data);
} catch (error) {
console.error('Error subscribing to events:', error.response ? error.response.data : error.message);
}
}
/**
* Verifies the webhook signature using Kick's public key.
* The signature is computed over a payload string of the format:
* "Kick-Event-Message-Id.Kick-Event-Message-Timestamp.rawBody"
*/
function verifyWebhook(req) {
const messageId = req.headers['kick-event-message-id'];
const timestamp = req.headers['kick-event-message-timestamp'];
const signatureHeader = req.headers['kick-event-signature'];
if (!messageId || !timestamp || !signatureHeader) {
console.error('Missing required webhook headers');
return false;
}
// Construct the payload string in the format "messageId.timestamp.body"
const payload = `${messageId}.${timestamp}.${req.rawBody}`;
const verifier = crypto.createVerify('sha256');
verifier.update(payload);
verifier.end();
// Decode the signature from base64
const signatureBuffer = Buffer.from(signatureHeader, 'base64');
const isValid = verifier.verify(KICK_PUBLIC_KEY, signatureBuffer);
if (!isValid) {
console.error('Invalid webhook signature');
}
return isValid;
}
/**
* Webhook endpoint for receiving chat messages.
* Kick will POST event payloads here.
*/
app.post('/webhook', (req, res) => {
if (!verifyWebhook(req)) {
return res.status(403).send('Forbidden: Invalid signature');
}
console.log('Received valid webhook:', req.body);
// Process the webhook payload as needed.
res.status(200).send('Webhook received successfully');
});
// OAuth endpoints
app.get('/login', oauthLogin);
app.get('/callback', oauthCallback);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment