Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.72% covered (warning)
87.72%
50 / 57
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ClientCredentialsTokenProvider
87.72% covered (warning)
87.72%
50 / 57
40.00% covered (danger)
40.00%
2 / 5
30.56
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
 token
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 fetch
91.18% covered (success)
91.18%
31 / 34
0.00% covered (danger)
0.00%
0 / 1
16.18
 load
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
6.56
 store
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Client;
6
7use Psr\Clock\ClockInterface;
8use Psr\Http\Client\ClientExceptionInterface;
9use Psr\Http\Client\ClientInterface;
10use Psr\Http\Message\RequestFactoryInterface;
11use Psr\Http\Message\StreamFactoryInterface;
12use Psr\SimpleCache\CacheInterface;
13use SensitiveParameter;
14use Throwable;
15
16/**
17 * OAuth 2.0 client credentials, as the ONE Record security model prescribes:
18 * POST grant_type=client_credentials to the partner's token endpoint, keep
19 * the token until shortly before it expires, then fetch a new one. Tokens are
20 * cached per endpoint and client id (PSR-16 when given, else in this object).
21 */
22final class ClientCredentialsTokenProvider implements TokenProvider
23{
24    /** @var array<string, array{token: string, expiresAt: int}> */
25    private array $memory = [];
26
27    /**
28     * @param ?string $scope the scope or audience parameter some token endpoints require (sent as `scope`)
29     * @param bool $basicAuth send the credentials as HTTP Basic (client_secret_basic) instead of form fields
30     * @param int $refreshMarginSeconds how long before expiry a token is considered stale
31     */
32    public function __construct(
33        private readonly ClientInterface $http,
34        private readonly RequestFactoryInterface $requests,
35        private readonly StreamFactoryInterface $streams,
36        private readonly ClockInterface $clock,
37        private readonly string $tokenUrl,
38        private readonly string $clientId,
39        #[SensitiveParameter]
40        private readonly string $clientSecret,
41        private readonly ?CacheInterface $cache = null,
42        private readonly ?string $scope = null,
43        private readonly bool $basicAuth = false,
44        private readonly int $refreshMarginSeconds = 60,
45    ) {}
46
47    public function token(string $serverEndpoint): string
48    {
49        $key = 'one-record.token.' . hash('sha256', $this->tokenUrl . '|' . $this->clientId . '|' . ($this->scope ?? ''));
50        $now = $this->clock->now()->getTimestamp();
51        $cached = $this->load($key);
52        if ($cached !== null && $cached['expiresAt'] - $this->refreshMarginSeconds > $now) {
53            return $cached['token'];
54        }
55
56        $fresh = $this->fetch();
57        $this->store($key, $fresh, max(1, $fresh['expiresAt'] - $now));
58
59        return $fresh['token'];
60    }
61
62    /**
63     * @return array{token: string, expiresAt: int}
64     */
65    private function fetch(): array
66    {
67        $fields = ['grant_type' => 'client_credentials'];
68        if ($this->scope !== null) {
69            $fields['scope'] = $this->scope;
70        }
71        $request = $this->requests->createRequest('POST', $this->tokenUrl)
72            ->withHeader('Content-Type', 'application/x-www-form-urlencoded')
73            ->withHeader('Accept', 'application/json');
74        if ($this->basicAuth) {
75            // RFC 6749 ยง2.3.1: each part is form-encoded before the pair is base64-encoded, so a colon or
76            // percent in a credential survives (AR-019).
77            $request = $request->withHeader('Authorization', 'Basic ' . base64_encode(urlencode($this->clientId) . ':' . urlencode($this->clientSecret)));
78        } else {
79            $fields['client_id'] = $this->clientId;
80            $fields['client_secret'] = $this->clientSecret;
81        }
82        $request = $request->withBody($this->streams->createStream(http_build_query($fields, '', '&', PHP_QUERY_RFC3986)));
83
84        try {
85            $response = $this->http->sendRequest($request);
86        } catch (ClientExceptionInterface $e) {
87            throw new ClientException(\sprintf('Token request to %s failed: %s', $this->tokenUrl, $e->getMessage()), 0, $e);
88        }
89        $body = (string) $response->getBody();
90        if ($response->getStatusCode() !== 200) {
91            // OAuth error bodies carry "error"; never echo the body, it may contain more than that.
92            $error = null;
93            try {
94                $decoded = json_decode($body, true, 16, JSON_THROW_ON_ERROR);
95                $error = \is_array($decoded) && \is_string($decoded['error'] ?? null) ? $decoded['error'] : null;
96            } catch (Throwable) {
97            }
98            throw new TokenEndpointException($response->getStatusCode(), $error, $this->tokenUrl);
99        }
100        try {
101            $decoded = json_decode($body, true, 16, JSON_THROW_ON_ERROR);
102        } catch (Throwable $e) {
103            throw new ClientException(\sprintf('Token endpoint %s did not answer with JSON.', $this->tokenUrl), 0, $e);
104        }
105        if (!\is_array($decoded)) {
106            throw new ClientException(\sprintf('Token endpoint %s did not answer with a JSON object.', $this->tokenUrl));
107        }
108        $token = $decoded['access_token'] ?? null;
109        if (!\is_string($token) || $token === '') {
110            throw new ClientException(\sprintf('Token endpoint %s answered without an access_token.', $this->tokenUrl));
111        }
112        $type = $decoded['token_type'] ?? 'Bearer';
113        if (!\is_string($type) || strcasecmp($type, 'Bearer') !== 0) {
114            throw new ClientException(\sprintf('Token endpoint %s issued a token of type "%s"; only Bearer is usable.', $this->tokenUrl, \is_string($type) ? $type : \gettype($type)));
115        }
116        $expiresIn = is_numeric($decoded['expires_in'] ?? null) ? (int) $decoded['expires_in'] : 3600;
117
118        return ['token' => $token, 'expiresAt' => $this->clock->now()->getTimestamp() + $expiresIn];
119    }
120
121    /**
122     * @return ?array{token: string, expiresAt: int}
123     */
124    private function load(string $key): ?array
125    {
126        if ($this->cache === null) {
127            return $this->memory[$key] ?? null;
128        }
129        try {
130            $value = $this->cache->get($key);
131        } catch (Throwable) {
132            return null;
133        }
134
135        return \is_array($value) && \is_string($value['token'] ?? null) && \is_int($value['expiresAt'] ?? null)
136            ? ['token' => $value['token'], 'expiresAt' => $value['expiresAt']]
137            : null;
138    }
139
140    /**
141     * @param array{token: string, expiresAt: int} $value
142     */
143    private function store(string $key, array $value, int $ttl): void
144    {
145        if ($this->cache === null) {
146            $this->memory[$key] = $value;
147
148            return;
149        }
150        try {
151            $this->cache->set($key, $value, $ttl);
152        } catch (Throwable) {
153            // A cache that cannot store only costs an extra token request next time.
154            $this->memory[$key] = $value;
155        }
156    }
157}