Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.56% covered (success)
97.56%
40 / 41
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
CalendarRsvpTokenService
97.50% covered (success)
97.50%
39 / 40
83.33% covered (warning)
83.33%
5 / 6
15
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generateToken
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 verifyToken
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 parseVerifiedPayload
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 base64UrlEncode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 base64UrlDecode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Calendar\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11/**
12 * Generates and validates cryptographic HMAC-SHA256 signed RSVP tokens for calendar invitations.
13 *
14 * Allows invitees to securely register RSVP decisions directly from email client links without
15 * requiring account credentials, while strictly protecting against replay and forgery.
16 *
17 * @package App\Modules\Calendar\Application\Service
18 */
19final readonly class CalendarRsvpTokenService
20{
21    private const string DEFAULT_SECRET = 'ammonly_calendar_rsvp_secret_salt_2026';
22    private const int DEFAULT_TTL_SECONDS = 2592000; // 30 days
23
24    public function __construct(
25        private string $secretKey = self::DEFAULT_SECRET,
26        private int $ttlSeconds = self::DEFAULT_TTL_SECONDS
27    ) {
28    }
29
30    /**
31     * Generates a signed, URL-safe RSVP token for a calendar event attendee.
32     *
33     * @param int    $calendarId    Event ID in database.
34     * @param string $attendeeEmail Attendee email address.
35     * @param int|null $customTtl   Optional custom TTL in seconds.
36     * @return string URL-safe signed token string.
37     */
38    public function generateToken(int $calendarId, string $attendeeEmail, ?int $customTtl = null): string
39    {
40        $ttl = $customTtl !== null ? $customTtl : $this->ttlSeconds;
41        $expiresAt = time() + $ttl;
42
43        $payload = [
44            'cid' => $calendarId,
45            'eml' => strtolower(trim($attendeeEmail)),
46            'exp' => $expiresAt,
47        ];
48
49        $encodedPayload = $this->base64UrlEncode((string) json_encode($payload, JSON_THROW_ON_ERROR));
50        $signature = hash_hmac('sha256', $encodedPayload, $this->secretKey, true);
51        $encodedSignature = $this->base64UrlEncode($signature);
52
53        return $encodedPayload . '.' . $encodedSignature;
54    }
55
56    /**
57     * Validates a signed RSVP token and returns the verified payload data.
58     *
59     * @param string $token Signed token string.
60     * @return array{calendar_id: int, attendee_email: string}|null Verified payload or null if invalid/expired.
61     */
62    public function verifyToken(string $token): ?array
63    {
64        $parts = explode('.', $token, 2);
65        if (count($parts) !== 2) {
66            return null;
67        }
68
69        [$encodedPayload, $encodedSignature] = $parts;
70        $expectedSignature = hash_hmac('sha256', $encodedPayload, $this->secretKey, true);
71        $actualSignature = $this->base64UrlDecode($encodedSignature);
72
73        if (!hash_equals($expectedSignature, $actualSignature)) {
74            return null;
75        }
76
77        return $this->parseVerifiedPayload($encodedPayload);
78    }
79
80    /**
81     * @return array{calendar_id: int, attendee_email: string}|null
82     */
83    private function parseVerifiedPayload(string $encodedPayload): ?array
84    {
85        $rawJson = $this->base64UrlDecode($encodedPayload);
86        $data = json_decode($rawJson, true);
87        if (!is_array($data)) {
88            return null;
89        }
90
91        $cid = (int) ($data['cid'] ?? 0);
92        $eml = (string) ($data['eml'] ?? '');
93        $exp = (int) ($data['exp'] ?? 0);
94
95        if ($cid <= 0 || $eml === '' || $exp < time()) {
96            return null;
97        }
98
99        return [
100            'calendar_id'    => $cid,
101            'attendee_email' => $eml,
102        ];
103    }
104
105    private function base64UrlEncode(string $data): string
106    {
107        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
108    }
109
110    private function base64UrlDecode(string $data): string
111    {
112        $remainder = strlen($data) % 4;
113        if ($remainder > 0) {
114            $data .= str_repeat('=', 4 - $remainder);
115        }
116
117        $decoded = base64_decode(strtr($data, '-_', '+/'), true);
118        return $decoded !== false ? $decoded : '';
119    }
120}