Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.55% covered (success)
96.55%
56 / 58
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
TokenEndpoint
96.55% covered (success)
96.55%
56 / 58
66.67% covered (warning)
66.67%
4 / 6
29
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
 handle
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
14
 formParameters
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
8.01
 basicCredentials
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 json
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Auth;
6
7use LambdaTwelve\OneRecord\Auth\Jwt\Claims;
8use LambdaTwelve\OneRecord\Auth\Jwt\Rs256Signer;
9use Psr\Http\Message\ResponseFactoryInterface;
10use Psr\Http\Message\ResponseInterface;
11use Psr\Http\Message\ServerRequestInterface;
12use Psr\Http\Message\StreamFactoryInterface;
13use Psr\Http\Server\RequestHandlerInterface;
14use Psr\Log\LoggerInterface;
15use Psr\Log\NullLogger;
16
17/**
18 * An OAuth 2.0 client-credentials token endpoint (RFC 6749 section 4.4) that
19 * issues the RS256 tokens ONE Record servers authenticate each other with.
20 *
21 * For a host with a handful of partners this replaces running an identity
22 * provider: each partner gets a client id and secret, exchanges them here for
23 * a short-lived token carrying logistics_agent_uri, and presents it on every
24 * call. Partners only ever see a token URL, so a real identity provider can
25 * take over later without them changing anything else. Rate limiting is the
26 * host's responsibility (a middleware in front of this handler).
27 */
28final class TokenEndpoint implements RequestHandlerInterface
29{
30    /**
31     * @param ?string $audience put in the aud claim so tokens are bound to one server
32     */
33    public function __construct(
34        private readonly ClientCredentialsVerifier $credentials,
35        private readonly Rs256Signer $signer,
36        private readonly ResponseFactoryInterface $responses,
37        private readonly StreamFactoryInterface $streams,
38        private readonly int $ttlSeconds = 3600,
39        private readonly ?string $audience = null,
40        private readonly LoggerInterface $logger = new NullLogger(),
41    ) {}
42
43    public function handle(ServerRequestInterface $request): ResponseInterface
44    {
45        if (strtoupper($request->getMethod()) !== 'POST') {
46            return $this->error(405, 'invalid_request', 'Use POST.')->withHeader('Allow', 'POST');
47        }
48
49        $params = $this->formParameters($request);
50        if ($params === null) {
51            // RFC 6749 §3.2: parameters MUST NOT be included more than once.
52            return $this->error(400, 'invalid_request', 'A parameter was repeated.');
53        }
54        if (($params['grant_type'] ?? null) !== 'client_credentials') {
55            return $this->error(400, 'unsupported_grant_type', 'Only the client_credentials grant is supported.');
56        }
57
58        $basic = $this->basicCredentials($request);
59        if ($basic !== null && (isset($params['client_id']) || isset($params['client_secret']))) {
60            // RFC 6749 §2.3: more than one authentication method is invalid_request, not a precedence question (R7-009).
61            return $this->error(400, 'invalid_request', 'Authenticate with HTTP Basic or with client_id and client_secret in the body, not both.');
62        }
63        [$clientId, $secret, $viaBasic] = $basic ?? [$params['client_id'] ?? null, $params['client_secret'] ?? null, false];
64        if (!\is_string($clientId) || $clientId === '' || !\is_string($secret) || $secret === '') {
65            return $this->error(400, 'invalid_request', 'client_id and client_secret are required.');
66        }
67
68        $agent = $this->credentials->verify($clientId, $secret);
69        if ($agent === null) {
70            $this->logger->warning('Token refused', ['client_id' => $clientId]);
71            $response = $this->error(401, 'invalid_client', 'Client authentication failed.');
72
73            return $viaBasic ? $response->withHeader('WWW-Authenticate', 'Basic realm="one-record"') : $response;
74        }
75
76        $claims = ['sub' => $clientId, Claims::LOGISTICS_AGENT_URI => $agent->value];
77        if ($this->audience !== null) {
78            $claims['aud'] = $this->audience;
79        }
80        $token = $this->signer->sign($claims, $this->ttlSeconds);
81        $this->logger->info('Token issued', ['client_id' => $clientId, 'agent' => $agent->value]);
82
83        return $this->json(200, ['access_token' => $token, 'token_type' => 'Bearer', 'expires_in' => $this->ttlSeconds]);
84    }
85
86    /**
87     * The form parameters, read from the raw body when there is one so a
88     * repeated parameter is seen rather than collapsed by parse_str().
89     *
90     * @return ?array<string, string> null when a parameter is repeated
91     */
92    private function formParameters(ServerRequestInterface $request): ?array
93    {
94        $raw = (string) $request->getBody();
95        if ($raw !== '') {
96            $params = [];
97            foreach (explode('&', $raw) as $pair) {
98                if ($pair === '') {
99                    continue;
100                }
101                [$key, $value] = array_pad(explode('=', $pair, 2), 2, '');
102                $key = urldecode($key);
103                if (\array_key_exists($key, $params)) {
104                    return null;
105                }
106                $params[$key] = urldecode($value);
107            }
108
109            return $params;
110        }
111        $parsed = $request->getParsedBody();
112        $params = [];
113        if (\is_array($parsed)) {
114            foreach ($parsed as $key => $value) {
115                if (\is_string($value)) {
116                    $params[(string) $key] = $value;
117                }
118            }
119        }
120
121        return $params;
122    }
123
124    /**
125     * @return ?array{string, string, true}
126     */
127    private function basicCredentials(ServerRequestInterface $request): ?array
128    {
129        $header = $request->getHeaderLine('Authorization');
130        if (preg_match('/^Basic\s+([A-Za-z0-9+\/=]+)\s*$/i', $header, $m) !== 1) {
131            return null;
132        }
133        $decoded = base64_decode($m[1], true);
134        if ($decoded === false || !str_contains($decoded, ':')) {
135            return null;
136        }
137        [$id, $secret] = explode(':', $decoded, 2);
138
139        // The parts are application/x-www-form-urlencoded (RFC 6749 §2.3.1): + is a space, %41 is A.
140        return [urldecode($id), urldecode($secret), true];
141    }
142
143    private function error(int $status, string $code, string $description): ResponseInterface
144    {
145        return $this->json($status, ['error' => $code, 'error_description' => $description]);
146    }
147
148    /**
149     * @param array<string, mixed> $body
150     */
151    private function json(int $status, array $body): ResponseInterface
152    {
153        return $this->responses->createResponse($status)
154            ->withHeader('Content-Type', 'application/json; charset=utf-8')
155            ->withHeader('Cache-Control', 'no-store')
156            ->withHeader('Pragma', 'no-cache')
157            ->withBody($this->streams->createStream(json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)));
158    }
159}