Last active
March 10, 2025 12:02
-
-
Save FelipeBudinich/1f59f24afc20371ffabdd3d8edda47ea to your computer and use it in GitHub Desktop.
Barebones KICK OAuth and Webhooks implementation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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