Skip to content

Commit 3097949

Browse files
chr-hertelclaude
andcommitted
fix: add RFC 7591 error codes, Content-Type validation, and docblock safety warning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d0e3077 commit 3097949

File tree

4 files changed

+70
-2
lines changed

4 files changed

+70
-2
lines changed

src/Exception/ClientRegistrationException.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,11 @@
1313

1414
final class ClientRegistrationException extends \RuntimeException implements ExceptionInterface
1515
{
16+
public function __construct(
17+
string $message,
18+
public readonly string $errorCode = 'invalid_client_metadata',
19+
?\Throwable $previous = null,
20+
) {
21+
parent::__construct($message, 0, $previous);
22+
}
1623
}

src/Server/Transport/Http/Middleware/ClientRegistrationMiddleware.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
6969

7070
private function handleRegistration(ServerRequestInterface $request): ResponseInterface
7171
{
72+
$contentType = $request->getHeaderLine('Content-Type');
73+
if (!str_starts_with($contentType, 'application/json')) {
74+
return $this->jsonResponse(400, [
75+
'error' => 'invalid_client_metadata',
76+
'error_description' => 'Content-Type must be application/json.',
77+
], ['Cache-Control' => 'no-store']);
78+
}
79+
7280
$body = $request->getBody()->__toString();
7381

7482
try {
@@ -95,7 +103,7 @@ private function handleRegistration(ServerRequestInterface $request): ResponseIn
95103
$result = $this->registrar->register($data);
96104
} catch (ClientRegistrationException $e) {
97105
return $this->jsonResponse(400, [
98-
'error' => 'invalid_client_metadata',
106+
'error' => $e->errorCode,
99107
'error_description' => $e->getMessage(),
100108
], ['Cache-Control' => 'no-store']);
101109
}

src/Server/Transport/Http/OAuth/ClientRegistrarInterface.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ interface ClientRegistrarInterface
3636
*
3737
* @return array<string, mixed> Registration response including client_id and optional client_secret
3838
*
39-
* @throws ClientRegistrationException If registration fails (e.g. invalid metadata, storage error)
39+
* @throws ClientRegistrationException If registration fails (e.g. invalid metadata, storage error).
40+
* The exception message is returned to the client as error_description —
41+
* do not include internal details (database errors, stack traces, etc.).
4042
*/
4143
public function register(array $registrationRequest): array;
4244
}

tests/Unit/Server/Transport/Http/Middleware/ClientRegistrationMiddlewareTest.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public function testRegistrationSuccess(): void
4444
$middleware = $this->createMiddleware($registrar);
4545

4646
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
47+
->withHeader('Content-Type', 'application/json')
4748
->withBody($this->factory->createStream(json_encode(['redirect_uris' => ['https://example.com/callback']])));
4849

4950
$response = $middleware->process($request, $this->createPassthroughHandler(404));
@@ -65,6 +66,7 @@ public function testRegistrationWithInvalidJson(): void
6566
$middleware = $this->createMiddleware($registrar);
6667

6768
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
69+
->withHeader('Content-Type', 'application/json')
6870
->withBody($this->factory->createStream('not json'));
6971

7072
$response = $middleware->process($request, $this->createPassthroughHandler(404));
@@ -84,6 +86,7 @@ public function testRegistrationWithJsonArrayReturns400(): void
8486
$middleware = $this->createMiddleware($registrar);
8587

8688
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
89+
->withHeader('Content-Type', 'application/json')
8790
->withBody($this->factory->createStream('["not","an","object"]'));
8891

8992
$response = $middleware->process($request, $this->createPassthroughHandler(404));
@@ -103,6 +106,7 @@ public function testRegistrationWithEmptyJsonArrayReturns400(): void
103106
$middleware = $this->createMiddleware($registrar);
104107

105108
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
109+
->withHeader('Content-Type', 'application/json')
106110
->withBody($this->factory->createStream('[]'));
107111

108112
$response = $middleware->process($request, $this->createPassthroughHandler(404));
@@ -138,6 +142,7 @@ public function testRegistrationWithNestedObjectsPassesAssociativeArrays(): void
138142
]);
139143

140144
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
145+
->withHeader('Content-Type', 'application/json')
141146
->withBody($this->factory->createStream($body));
142147

143148
$response = $middleware->process($request, $this->createPassthroughHandler(404));
@@ -153,12 +158,14 @@ public function testRegistrationErrorResponsesIncludeCacheControl(): void
153158

154159
// Invalid JSON
155160
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
161+
->withHeader('Content-Type', 'application/json')
156162
->withBody($this->factory->createStream('not json'));
157163
$response = $middleware->process($request, $this->createPassthroughHandler(404));
158164
$this->assertSame('no-store', $response->getHeaderLine('Cache-Control'));
159165

160166
// JSON array (not object)
161167
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
168+
->withHeader('Content-Type', 'application/json')
162169
->withBody($this->factory->createStream('["array"]'));
163170
$response = $middleware->process($request, $this->createPassthroughHandler(404));
164171
$this->assertSame('no-store', $response->getHeaderLine('Cache-Control'));
@@ -175,6 +182,7 @@ public function testRegistrationWithRegistrarException(): void
175182
$middleware = $this->createMiddleware($registrar);
176183

177184
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
185+
->withHeader('Content-Type', 'application/json')
178186
->withBody($this->factory->createStream('{}'));
179187

180188
$response = $middleware->process($request, $this->createPassthroughHandler(404));
@@ -187,6 +195,49 @@ public function testRegistrationWithRegistrarException(): void
187195
$this->assertSame('redirect_uris is required', $payload['error_description']);
188196
}
189197

198+
#[TestDox('POST /register without application/json Content-Type returns 400')]
199+
public function testRegistrationRejectsNonJsonContentType(): void
200+
{
201+
$registrar = $this->createStub(ClientRegistrarInterface::class);
202+
$middleware = $this->createMiddleware($registrar);
203+
204+
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
205+
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
206+
->withBody($this->factory->createStream('key=value'));
207+
208+
$response = $middleware->process($request, $this->createPassthroughHandler(404));
209+
210+
$this->assertSame(400, $response->getStatusCode());
211+
$this->assertSame('no-store', $response->getHeaderLine('Cache-Control'));
212+
213+
$payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
214+
$this->assertSame('invalid_client_metadata', $payload['error']);
215+
$this->assertSame('Content-Type must be application/json.', $payload['error_description']);
216+
}
217+
218+
#[TestDox('POST /register uses error code from ClientRegistrationException')]
219+
public function testRegistrationUsesCustomErrorCode(): void
220+
{
221+
$registrar = $this->createMock(ClientRegistrarInterface::class);
222+
$registrar->expects($this->once())
223+
->method('register')
224+
->willThrowException(new ClientRegistrationException('Invalid redirect URI', 'invalid_redirect_uri'));
225+
226+
$middleware = $this->createMiddleware($registrar);
227+
228+
$request = $this->factory->createServerRequest('POST', 'http://localhost:8000/register')
229+
->withHeader('Content-Type', 'application/json')
230+
->withBody($this->factory->createStream('{}'));
231+
232+
$response = $middleware->process($request, $this->createPassthroughHandler(404));
233+
234+
$this->assertSame(400, $response->getStatusCode());
235+
236+
$payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR);
237+
$this->assertSame('invalid_redirect_uri', $payload['error']);
238+
$this->assertSame('Invalid redirect URI', $payload['error_description']);
239+
}
240+
190241
#[TestDox('GET /.well-known/oauth-authorization-server enriches response with registration_endpoint')]
191242
public function testMetadataEnrichment(): void
192243
{

0 commit comments

Comments
 (0)