Skip to content

Commit bd3e4fb

Browse files
authored
Structured responses to webhook events (#265)
* Add WebhookResponse class for handling webhook actions and responses * Refactor WebhookResponse create method and improve validation * Resolve linting error --------- Co-authored-by: Braden Keith <bkeith@romegadigital.com>
1 parent f8d7b96 commit bd3e4fb

File tree

3 files changed

+275
-2
lines changed

3 files changed

+275
-2
lines changed

lib/Resource/WebhookResponse.php

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
namespace WorkOS\Resource;
4+
5+
use WorkOS\Webhook;
6+
7+
/**
8+
* Class WebhookResponse.
9+
*
10+
* This class represents the response structure for WorkOS webhook actions.
11+
*/
12+
class WebhookResponse
13+
{
14+
public const USER_REGISTRATION_ACTION = 'user_registration_action_response';
15+
public const AUTHENTICATION_ACTION = 'authentication_action_response';
16+
public const VERDICT_ALLOW = 'Allow';
17+
public const VERDICT_DENY = 'Deny';
18+
19+
/**
20+
* @var string
21+
*/
22+
private $object;
23+
24+
/**
25+
* @var array
26+
*/
27+
private $payload;
28+
29+
/**
30+
* @var string
31+
*/
32+
private $signature;
33+
34+
/**
35+
* Create a new WebhookResponse instance
36+
*
37+
* @param string $type Either USER_REGISTRATION_ACTION or AUTHENTICATION_ACTION
38+
* @param string $secret Webhook secret for signing the response
39+
* @param string $verdict Either VERDICT_ALLOW or VERDICT_DENY
40+
* @param string|null $errorMessage Required if verdict is VERDICT_DENY
41+
* @return self
42+
* @throws \InvalidArgumentException
43+
*/
44+
public static function create($type, $secret, $verdict, $errorMessage = null)
45+
{
46+
if (!in_array($type, [self::USER_REGISTRATION_ACTION, self::AUTHENTICATION_ACTION])) {
47+
throw new \InvalidArgumentException('Invalid response type');
48+
}
49+
50+
if (empty($secret)) {
51+
throw new \InvalidArgumentException('Secret is required');
52+
}
53+
54+
if (!in_array($verdict, [self::VERDICT_ALLOW, self::VERDICT_DENY])) {
55+
throw new \InvalidArgumentException('Invalid verdict');
56+
}
57+
58+
if ($verdict === self::VERDICT_DENY && empty($errorMessage)) {
59+
throw new \InvalidArgumentException('Error message is required when verdict is Deny');
60+
}
61+
62+
$instance = new self();
63+
$instance->object = $type;
64+
65+
$payload = [
66+
'timestamp' => time() * 1000,
67+
'verdict' => $verdict
68+
];
69+
70+
if ($verdict === self::VERDICT_DENY) {
71+
$payload['error_message'] = $errorMessage;
72+
}
73+
74+
$instance->payload = $payload;
75+
76+
$timestamp = $payload['timestamp'];
77+
$payloadString = json_encode($payload);
78+
$instance->signature = (new Webhook())->computeSignature($timestamp, $payloadString, $secret);
79+
80+
return $instance;
81+
}
82+
83+
/**
84+
* Get the response as an array
85+
*
86+
* @return array
87+
*/
88+
public function toArray()
89+
{
90+
$response = [
91+
'object' => $this->object,
92+
'payload' => $this->payload
93+
];
94+
95+
if ($this->signature) {
96+
$response['signature'] = $this->signature;
97+
}
98+
99+
return $response;
100+
}
101+
102+
/**
103+
* Get the response as a JSON string
104+
*
105+
* @return string
106+
*/
107+
public function toJson()
108+
{
109+
return json_encode($this->toArray());
110+
}
111+
}

lib/Webhook.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@ public function verifyHeader($sigHeader, $payload, $secret, $tolerance)
4444
$signature = $this->getSignature($sigHeader);
4545

4646
$currentTime = time();
47-
$signedPayload = $timestamp . "." . $payload;
48-
$expectedSignature = hash_hmac("sha256", $signedPayload, $secret, false);
47+
$expectedSignature = $this->computeSignature($timestamp, $payload, $secret);
4948

5049
if (empty($timestamp)) {
5150
return "No Timestamp available";
@@ -89,4 +88,18 @@ public function getSignature($sigHeader)
8988

9089
return $signature;
9190
}
91+
92+
/**
93+
* Computes a signature for a webhook payload using the provided timestamp and secret
94+
*
95+
* @param int $timestamp Unix timestamp to use in signature
96+
* @param string $payload The payload to sign
97+
* @param string $secret Secret key used for signing
98+
* @return string The computed HMAC SHA-256 signature
99+
*/
100+
public function computeSignature($timestamp, $payload, $secret)
101+
{
102+
$signedPayload = $timestamp . '.' . $payload;
103+
return hash_hmac('sha256', $signedPayload, $secret, false);
104+
}
92105
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
namespace WorkOS\Resource;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use WorkOS\TestHelper;
7+
8+
class WebhookResponseTest extends TestCase
9+
{
10+
use TestHelper {
11+
setUp as protected traitSetUp;
12+
}
13+
14+
/**
15+
* @var string
16+
*/
17+
protected $secret;
18+
19+
/**
20+
* @var int
21+
*/
22+
protected $timestamp;
23+
24+
protected function setUp(): void
25+
{
26+
$this->traitSetUp();
27+
$this->withApiKey();
28+
29+
$this->secret = 'test_secret';
30+
$this->timestamp = time() * 1000; // milliseconds
31+
}
32+
33+
public function testCreateAllowResponse()
34+
{
35+
$response = WebhookResponse::create(
36+
WebhookResponse::USER_REGISTRATION_ACTION,
37+
$this->secret,
38+
WebhookResponse::VERDICT_ALLOW
39+
);
40+
41+
$array = $response->toArray();
42+
43+
$this->assertEquals(WebhookResponse::USER_REGISTRATION_ACTION, $array['object']);
44+
$this->assertArrayHasKey('payload', $array);
45+
$this->assertArrayHasKey('signature', $array);
46+
$this->assertEquals(WebhookResponse::VERDICT_ALLOW, $array['payload']['verdict']);
47+
$this->assertArrayHasKey('timestamp', $array['payload']);
48+
$this->assertArrayNotHasKey('error_message', $array['payload']);
49+
}
50+
51+
public function testCreateDenyResponse()
52+
{
53+
$errorMessage = 'Registration denied due to risk assessment';
54+
$response = WebhookResponse::create(
55+
WebhookResponse::USER_REGISTRATION_ACTION,
56+
$this->secret,
57+
WebhookResponse::VERDICT_DENY,
58+
$errorMessage
59+
);
60+
61+
$array = $response->toArray();
62+
63+
$this->assertEquals(WebhookResponse::USER_REGISTRATION_ACTION, $array['object']);
64+
$this->assertArrayHasKey('payload', $array);
65+
$this->assertArrayHasKey('signature', $array);
66+
$this->assertEquals(WebhookResponse::VERDICT_DENY, $array['payload']['verdict']);
67+
$this->assertEquals($errorMessage, $array['payload']['error_message']);
68+
$this->assertArrayHasKey('timestamp', $array['payload']);
69+
}
70+
71+
public function testCreateAuthenticationResponse()
72+
{
73+
$response = WebhookResponse::create(
74+
WebhookResponse::AUTHENTICATION_ACTION,
75+
$this->secret,
76+
WebhookResponse::VERDICT_ALLOW
77+
);
78+
79+
$array = $response->toArray();
80+
81+
$this->assertEquals(WebhookResponse::AUTHENTICATION_ACTION, $array['object']);
82+
$this->assertArrayHasKey('payload', $array);
83+
$this->assertArrayHasKey('signature', $array);
84+
}
85+
86+
public function testCreateWithoutSecret()
87+
{
88+
$this->expectException(\InvalidArgumentException::class);
89+
$this->expectExceptionMessage('Secret is required');
90+
91+
WebhookResponse::create(
92+
WebhookResponse::USER_REGISTRATION_ACTION,
93+
'',
94+
WebhookResponse::VERDICT_ALLOW
95+
);
96+
}
97+
98+
public function testInvalidResponseType()
99+
{
100+
$this->expectException(\InvalidArgumentException::class);
101+
$this->expectExceptionMessage('Invalid response type');
102+
103+
WebhookResponse::create(
104+
'invalid_type',
105+
$this->secret,
106+
WebhookResponse::VERDICT_ALLOW
107+
);
108+
}
109+
110+
public function testInvalidVerdict()
111+
{
112+
$this->expectException(\InvalidArgumentException::class);
113+
$this->expectExceptionMessage('Invalid verdict');
114+
115+
WebhookResponse::create(
116+
WebhookResponse::USER_REGISTRATION_ACTION,
117+
$this->secret,
118+
'invalid_verdict'
119+
);
120+
}
121+
122+
public function testDenyWithoutErrorMessage()
123+
{
124+
$this->expectException(\InvalidArgumentException::class);
125+
$this->expectExceptionMessage('Error message is required when verdict is Deny');
126+
127+
WebhookResponse::create(
128+
WebhookResponse::USER_REGISTRATION_ACTION,
129+
$this->secret,
130+
WebhookResponse::VERDICT_DENY
131+
);
132+
}
133+
134+
public function testToJson()
135+
{
136+
$response = WebhookResponse::create(
137+
WebhookResponse::USER_REGISTRATION_ACTION,
138+
$this->secret,
139+
WebhookResponse::VERDICT_ALLOW
140+
);
141+
142+
$json = $response->toJson();
143+
$decoded = json_decode($json, true);
144+
145+
$this->assertIsString($json);
146+
$this->assertIsArray($decoded);
147+
$this->assertEquals(WebhookResponse::USER_REGISTRATION_ACTION, $decoded['object']);
148+
}
149+
}

0 commit comments

Comments
 (0)