Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.12% covered (success)
94.12%
16 / 17
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
PreviewToken
94.12% covered (success)
94.12%
16 / 17
83.33% covered (warning)
83.33%
5 / 6
14.04
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
 fromConfig
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 issue
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 uid
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
7.14
 sign
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 encode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Api;
6
7use Cosray\Config;
8
9/**
10 * A signed, expiring grant to read one node's working copy through the API,
11 * which the panel hands to a headless frontend's preview. It needs no
12 * storage: the uid and the expiry travel in the token, signed with a key
13 * derived from `app.secret`.
14 */
15final class PreviewToken
16{
17    public const int LIFETIME = 3600;
18
19    private readonly string $key;
20
21    public function __construct(
22        #[\SensitiveParameter]
23        string $secret,
24    ) {
25        $this->key = hash_hmac('sha256', 'cosray-api-preview', $secret, true);
26    }
27
28    /** Null without `app.secret`, which signing needs. */
29    public static function fromConfig(Config $config): ?self
30    {
31        $secret = $config->app->secret;
32
33        return is_string($secret) && $secret !== '' ? new self($secret) : null;
34    }
35
36    public function issue(string $uid, ?int $expires = null): string
37    {
38        $payload = self::encode(json_encode(
39            ['uid' => $uid, 'exp' => $expires ?? (time() + self::LIFETIME)],
40            JSON_THROW_ON_ERROR,
41        ));
42
43        return $payload . '.' . $this->sign($payload);
44    }
45
46    /** The uid the token grants, or null when it is forged, malformed or expired. */
47    public function uid(
48        #[\SensitiveParameter]
49        string $token,
50        ?int $now = null,
51    ): ?string {
52        $parts = explode('.', $token);
53
54        if (count($parts) !== 2 || !hash_equals($this->sign($parts[0]), $parts[1])) {
55            return null;
56        }
57
58        $data = json_decode((string) base64_decode(strtr($parts[0], '-_', '+/'), true), true);
59
60        if (!is_array($data) || !is_string($data['uid'] ?? null) || !is_int($data['exp'] ?? null)) {
61            return null;
62        }
63
64        return $data['exp'] >= ($now ?? time()) ? $data['uid'] : null;
65    }
66
67    private function sign(string $payload): string
68    {
69        return self::encode(hash_hmac('sha256', $payload, $this->key, true));
70    }
71
72    private static function encode(string $bytes): string
73    {
74        return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
75    }
76}