Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.55% |
56 / 58 |
|
66.67% |
4 / 6 |
CRAP | |
0.00% |
0 / 1 |
| TokenEndpoint | |
96.55% |
56 / 58 |
|
66.67% |
4 / 6 |
29 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| handle | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
14 | |||
| formParameters | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
8.01 | |||
| basicCredentials | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
| error | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| json | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Auth; |
| 6 | |
| 7 | use LambdaTwelve\OneRecord\Auth\Jwt\Claims; |
| 8 | use LambdaTwelve\OneRecord\Auth\Jwt\Rs256Signer; |
| 9 | use Psr\Http\Message\ResponseFactoryInterface; |
| 10 | use Psr\Http\Message\ResponseInterface; |
| 11 | use Psr\Http\Message\ServerRequestInterface; |
| 12 | use Psr\Http\Message\StreamFactoryInterface; |
| 13 | use Psr\Http\Server\RequestHandlerInterface; |
| 14 | use Psr\Log\LoggerInterface; |
| 15 | use 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 | */ |
| 28 | final 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 | } |