From 7f70f75d057e1e43842745b8d86f91787d815074 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sat, 8 Jun 2024 10:56:15 +0100 Subject: [PATCH] IndieAuth endpoint can now return access tokens --- app/Http/Controllers/IndieAuthController.php | 225 ++++++-- .../Controllers/TokenEndpointController.php | 109 ---- bootstrap/app.php | 3 +- routes/web.php | 19 +- tests/Feature/IndieAuthTest.php | 498 ++++++++++++++++++ tests/Feature/TokenEndpointTest.php | 73 --- 6 files changed, 683 insertions(+), 244 deletions(-) delete mode 100644 app/Http/Controllers/TokenEndpointController.php delete mode 100644 tests/Feature/TokenEndpointTest.php diff --git a/app/Http/Controllers/IndieAuthController.php b/app/Http/Controllers/IndieAuthController.php index 845107ce..b3330ae5 100644 --- a/app/Http/Controllers/IndieAuthController.php +++ b/app/Http/Controllers/IndieAuthController.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace App\Http\Controllers; +use App\Services\TokenService; use Exception; use GuzzleHttp\Client; use GuzzleHttp\Psr7\Uri; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Validator; @@ -17,6 +19,18 @@ use SodiumException; class IndieAuthController extends Controller { + public function indieAuthMetadataEndpoint(): JsonResponse + { + return response()->json([ + 'issuer' => config('app.url'), + 'authorization_endpoint' => route('indieauth.start'), + 'token_endpoint' => route('indieauth.token'), + 'code_challenge_methods_supported' => ['S256'], + //'introspection_endpoint' => route('indieauth.introspection'), + //'introspection_endpoint_auth_methods_supported' => ['none'], + ]); + } + /** * Process a GET request to the IndieAuth endpoint. * @@ -78,7 +92,7 @@ class IndieAuthController extends Controller * * @throws RandomException */ - public function confirm(Request $request): JsonResponse + public function confirm(Request $request): RedirectResponse { $authCode = bin2hex(random_bytes(16)); @@ -88,7 +102,9 @@ class IndieAuthController extends Controller 'code_challenge' => $request->get('code_challenge'), 'code_challenge_method' => $request->get('code_challenge_method'), 'client_id' => $request->get('client_id'), + 'redirect_uri' => $request->get('redirect_uri'), 'auth_code' => $authCode, + 'scopes' => $request->get('scopes', ''), ]; Cache::put($cacheKey, $indieAuthRequestData, now()->addMinutes(10)); @@ -96,62 +112,33 @@ class IndieAuthController extends Controller $redirectUri = new Uri($request->get('redirect_uri')); $redirectUri = Uri::withQueryValues($redirectUri, [ 'code' => $authCode, - 'me' => $request->get('me'), 'state' => $request->get('state'), + 'iss' => config('app.url'), ]); // For now just dump URL scheme - return response()->json([ - 'redirect_uri' => $redirectUri, - ]); + // return response()->json([ + // 'redirect_uri' => $redirectUri, + // ]); + + return redirect()->away($redirectUri); } /** - * Process a POST request to the IndieAuth endpoint. + * Process a POST request to the IndieAuth auth endpoint. + * + * This is one possible second step in the IndieAuth flow, where the client app sends the auth code to the IndieAuth + * endpoint. As it is to the auth endpoint we return profile information. A similar request can be made to the token + * endpoint to get an access token. * - * This is the second step in the IndieAuth flow, where the client app sends the auth code to the IndieAuth endpoint. * @throws SodiumException */ public function processCodeExchange(Request $request): JsonResponse { - // First check all the data is present - $validator = Validator::make($request->all(), [ - 'grant_type' => 'required:string', - 'code' => 'required:string', - 'client_id' => 'required', - 'redirect_uri' => 'required', - 'code_verifier' => 'required', - ]); + $invalidCodeResponse = $this->validateAuthorizationCode($request); - if ($validator->fails()) { - return response()->json($validator->errors(), 400); - } - - if ($request->get('grant_type') !== 'authorization_code') { - return response()->json(['error' => 'only a grant_type of "authorization_code" is supported'], 400); - } - - // Check cache for auth code - $cacheKey = hash('xxh3', $request->get('client_id')); - $indieAuthRequestData = Cache::pull($cacheKey); - - if ($indieAuthRequestData === null) { - return response()->json(['error' => 'code is invalid'], 404); - } - - if ($indieAuthRequestData['auth_code'] !== $request->get('code')) { - return response()->json(['error' => 'code is invalid'], 400); - } - - // Check code verifier - if (! hash_equals( - $indieAuthRequestData['code_challenge'], - sodium_bin2base64( - hash('sha256', $request->get('code_verifier'), true), - SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING - ) - )) { - return response()->json(['error' => 'code_verifier is invalid'], 400); + if ($invalidCodeResponse instanceof JsonResponse) { + return $invalidCodeResponse; } return response()->json([ @@ -159,6 +146,46 @@ class IndieAuthController extends Controller ]); } + /** + * Process a POST request to the IndieAuth token endpoint. + * + * This is another possible second step in the IndieAuth flow, where the client app sends the auth code to the + * IndieAuth token endpoint. As it is to the token endpoint we return an access token. + * + * @throws SodiumException + */ + public function processTokenRequest(Request $request): JsonResponse + { + $indieAuthData = $this->validateAuthorizationCode($request); + + if ($indieAuthData instanceof JsonResponse) { + return $indieAuthData; + } + + if ($indieAuthData['scopes'] === '') { + return response()->json(['errors' => [ + 'scope' => [ + 'The scope property must be non-empty for an access token to be issued.', + ], + ]], 400); + } + + $tokenData = [ + 'me' => config('app.url'), + 'client_id' => $request->get('client_id'), + 'scope' => $indieAuthData['scopes'], + ]; + $tokenService = resolve(TokenService::class); + $token = $tokenService->getNewToken($tokenData); + + return response()->json([ + 'access_token' => $token, + 'token_type' => 'Bearer', + 'scope' => $indieAuthData['scopes'], + 'me' => config('app.url'), + ]); + } + protected function isValidRedirectUri(string $clientId, string $redirectUri): bool { // If client_id is not a valid URL, then it's not valid @@ -197,6 +224,114 @@ class IndieAuthController extends Controller $redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? []; - return in_array($redirectUri, $redirectUris); + return in_array($redirectUri, $redirectUris, true); + } + + protected function validateAuthorizationCode(Request $request): JsonResponse|array + { + // First check all the data is present + $validator = Validator::make($request->all(), [ + 'grant_type' => 'required:string', + 'code' => 'required:string', + 'client_id' => 'required', + 'redirect_uri' => 'required', + 'code_verifier' => 'required', + ]); + + if ($validator->fails()) { + return response()->json(['errors' => $validator->errors()], 400); + } + + if ($request->get('grant_type') !== 'authorization_code') { + return response()->json(['errors' => [ + 'grant_type' => [ + 'Only a grant type of "authorization_code" is supported.', + ], + ]], 400); + } + + // Check cache for auth code + $cacheKey = hash('xxh3', $request->get('client_id')); + $indieAuthRequestData = Cache::pull($cacheKey); + + if ($indieAuthRequestData === null) { + return response()->json(['errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ]], 404); + } + + // Check the IndieAuth code + if (! array_key_exists('auth_code', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ]], 400); + } + if ($indieAuthRequestData['auth_code'] !== $request->get('code')) { + return response()->json(['errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ]], 400); + } + + // Check code verifier + if (! array_key_exists('code_challenge', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'code_verifier' => [ + 'The code verifier is invalid.', + ], + ]], 400); + } + if (! hash_equals( + $indieAuthRequestData['code_challenge'], + sodium_bin2base64( + hash('sha256', $request->get('code_verifier'), true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ) + )) { + return response()->json(['errors' => [ + 'code_verifier' => [ + 'The code verifier is invalid.', + ], + ]], 400); + } + + // Check redirect_uri + if (! array_key_exists('redirect_uri', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'redirect_uri' => [ + 'The redirect uri is invalid.', + ], + ]], 400); + } + if ($indieAuthRequestData['redirect_uri'] !== $request->get('redirect_uri')) { + return response()->json(['errors' => [ + 'redirect_uri' => [ + 'The redirect uri is invalid.', + ], + ]], 400); + } + + // Check client_id + if (! array_key_exists('client_id', $indieAuthRequestData)) { + return response()->json(['errors' => [ + 'client_id' => [ + 'The client id is invalid.', + ], + ]], 400); + } + if ($indieAuthRequestData['client_id'] !== $request->get('client_id')) { + return response()->json(['errors' => [ + 'client_id' => [ + 'The client id is invalid.', + ], + ]], 400); + } + + return $indieAuthRequestData; } } diff --git a/app/Http/Controllers/TokenEndpointController.php b/app/Http/Controllers/TokenEndpointController.php deleted file mode 100644 index 3be0af66..00000000 --- a/app/Http/Controllers/TokenEndpointController.php +++ /dev/null @@ -1,109 +0,0 @@ -client = $client; - $this->guzzle = $guzzle; - $this->tokenService = $tokenService; - } - - /** - * If the user has auth’d via the IndieAuth protocol, issue a valid token. - */ - public function create(Request $request): JsonResponse - { - $auth = $this->verifyIndieAuthCode( - config('url.authorization_endpoint'), - $request->input('code'), - $request->input('redirect_uri'), - $request->input('client_id'), - ); - - if ($auth === null || ! array_key_exists('me', $auth)) { - return response()->json([ - 'error' => 'There was an error verifying the IndieAuth code', - ], 401); - } - - $scope = $auth['scope'] ?? ''; - $tokenData = [ - 'me' => config('app.url'), - 'client_id' => $request->input('client_id'), - 'scope' => $scope, - ]; - $token = $this->tokenService->getNewToken($tokenData); - $content = [ - 'me' => config('app.url'), - 'scope' => $scope, - 'access_token' => $token, - ]; - - return response()->json($content); - } - - protected function verifyIndieAuthCode( - string $authorizationEndpoint, - string $code, - string $redirectUri, - string $clientId - ): ?array { - try { - $response = $this->guzzle->request('POST', $authorizationEndpoint, [ - 'headers' => [ - 'Accept' => 'application/json', - ], - 'form_params' => [ - 'code' => $code, - 'me' => config('app.url'), - 'redirect_uri' => $redirectUri, - 'client_id' => $clientId, - ], - ]); - } catch (BadResponseException) { - return null; - } - - try { - $authData = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException) { - return null; - } - - return $authData; - } -} diff --git a/bootstrap/app.php b/bootstrap/app.php index 32838ed3..3e55ca98 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,7 +12,8 @@ return Application::configure(basePath: dirname(__DIR__)) ) ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ - 'api/token', + 'auth', // This is the IndieAuth auth endpoint + 'token', // This is the IndieAuth token endpoint 'api/post', 'api/media', 'micropub/places', diff --git a/routes/web.php b/routes/web.php index 6cf7730f..aed8e64d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -192,24 +192,11 @@ Route::domain(config('url.longurl'))->group(function () { }); // IndieAuth - Route::get('auth', [IndieAuthController::class, 'start'])->middleware(MyAuthMiddleware::class); + Route::get('.well-known/indieauth-server', [IndieAuthController::class, 'indieAuthMetadataEndpoint']); + Route::get('auth', [IndieAuthController::class, 'start'])->middleware(MyAuthMiddleware::class)->name('indieauth.start'); Route::post('auth/confirm', [IndieAuthController::class, 'confirm'])->middleware(MyAuthMiddleware::class); Route::post('auth', [IndieAuthController::class, 'processCodeExchange']); - - Route::get('/test-auth-cache', function () { - $cacheKey = hash('xxh3', 'http://jonnybarnes.localhost'); - - dump(Cache::get($cacheKey)); - }); - - Route::get('/test-me', function () { - return response()->json([ - 'me' => config('app.url'), - ]); - }); - - // Token Endpoint - Route::post('api/token', [TokenEndpointController::class, 'create']); + Route::post('token', [IndieAuthController::class, 'processTokenRequest'])->name('indieauth.token'); // Micropub Endpoints Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class); diff --git a/tests/Feature/IndieAuthTest.php b/tests/Feature/IndieAuthTest.php index 4dae46b5..39d90cd3 100644 --- a/tests/Feature/IndieAuthTest.php +++ b/tests/Feature/IndieAuthTest.php @@ -5,7 +5,14 @@ declare(strict_types=1); namespace Tests\Feature; use App\Models\User; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\UriResolver; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -13,6 +20,44 @@ class IndieAuthTest extends TestCase { use RefreshDatabase; + #[Test] + public function itShouldReturnIndieAuthMetadata(): void + { + $response = $this->get('/.well-known/indieauth-server'); + + $response->assertStatus(200); + $response->assertJson([ + 'issuer' => config('app.url'), + 'authorization_endpoint' => route('indieauth.start'), + 'token_endpoint' => route('indieauth.token'), + 'code_challenge_methods_supported' => ['S256'], + //'introspection_endpoint' => 'introspection_endpoint', + //'introspection_endpoint_auth_methods_supported' => ['none'], + ]); + } + + #[Test] + public function itShouldRequireAdminLoginToShowAuthoriseForm(): void + { + $response = $this->get('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'https://app.example.com/callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'S256', + ]); + + $response->assertStatus(302); + $response->assertRedirect('/login'); + } + + /** + * The test passes here because the client_id and redirect_uri are on the + * same domain, later test will check the flow when they are different. + */ #[Test] public function itShouldReturnApprovalViewWhenTheRequestIsValid(): void { @@ -203,4 +248,457 @@ class IndieAuthTest extends TestCase $response->assertViewIs('indieauth.error'); $response->assertSee('only a code_challenge_method of "S256" is supported'); } + + #[Test] + public function itShouldCheckClientIdForValidRedirect(): void + { + // Mock Guzzle request for client_id + $appPageHtml = <<<'HTML' + + + + + Example App + + + +
+ Example App +
+ + + HTML; + + $mockHandler = new MockHandler([ + new Response(200, [], $appPageHtml), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockGuzzleClient = new Client(['handler' => $handlerStack]); + $this->app->instance(Client::class, $mockGuzzleClient); + + $user = User::factory()->make(); + $url = url()->query('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'S256', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.start'); + } + + #[Test] + public function itShouldErrorIfClientIdPageHasNoValidRedirect(): void + { + // Mock Guzzle request for client_id + $appPageHtml = <<<'HTML' + + + + + Example App + + +
+ Example App +
+ + + HTML; + + $mockHandler = new MockHandler([ + new Response(200, [], $appPageHtml), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $mockGuzzleClient = new Client(['handler' => $handlerStack]); + $this->app->instance(Client::class, $mockGuzzleClient); + + $user = User::factory()->make(); + $url = url()->query('/auth', [ + 'response_type' => 'code', + 'me' => 'https://example.com', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'state' => '123456', + 'scopes' => 'create update delete', + 'code_challenge' => '123456', + 'code_challenge_method' => 'S256', + ]); + + $response = $this->actingAs($user)->get($url); + + $response->assertStatus(200); + $response->assertViewIs('indieauth.error'); + $response->assertSee('redirect_uri is not valid for this client_id'); + } + + #[Test] + public function itShouldRedirectToAppOnApproval(): void + { + $user = User::factory()->make(); + $response = $this->actingAs($user)->post('/auth/confirm', [ + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'state' => '123456', + 'me' => 'https://example.com', + 'scope' => [ + 'create', + 'update', + 'delete', + ], + 'code_challenge' => '123abc', + 'code_challenge_method' => 'S256', + ]); + + $response->assertStatus(302); + + // Parse the redirect URL and check the query parameters + // the `code` will be random, but we can check its present + // and check the other parameters are correct + $redirectUri = $response->headers->get('Location'); + $resolvedRedirectUri = UriResolver::resolve(new Uri('example-app://callback'), new Uri($redirectUri)); + $query = $resolvedRedirectUri->getQuery(); + $parts = explode('&', $query); + $this->assertCount(3, $parts); + $this->assertStringContainsString('code=', $parts[0]); + $this->assertSame('state=123456', $parts[1]); + $this->assertSame('iss=' . config('app.url'), $parts[2]); + } + + #[Test] + public function itShouldShowErrorResponseWhenApprovalRequestIsMissingGrantType(): void + { + $response = $this->post('/auth', [ + 'code' => '123456', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'grant_type' => [ + 'The grant type field is required.', + ], + ], + ]); + } + + #[Test] + public function itShouldShowErrorResponseWhenApprovalRequestIsMissingCode(): void + { + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'code' => [ + 'The code field is required.', + ], + ], + ]); + } + + #[Test] + public function itShouldShowErrorResponseWhenApprovalRequestIsMissingClientId(): void + { + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'client_id' => [ + 'The client id field is required.', + ], + ], + ]); + } + + #[Test] + public function itShouldShowErrorResponseWhenApprovalRequestIsMissingRedirectUri(): void + { + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', + 'client_id' => 'https://app.example.com', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'redirect_uri' => [ + 'The redirect uri field is required.', + ], + ], + ]); + } + + #[Test] + public function itShouldShowErrorResponseWhenApprovalRequestIsMissingCodeVerifier(): void + { + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'code_verifier' => [ + 'The code verifier field is required.', + ], + ], + ]); + } + + #[Test] + public function itShouldShowErrorResponseWhenApprovalRequestGrantTypeIsUnsupported(): void + { + $response = $this->post('/auth', [ + 'grant_type' => 'unsupported', + 'code' => '123456', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'grant_type' => [ + 'Only a grant type of "authorization_code" is supported.', + ], + ], + ]); + } + + #[Test] + public function itShouldReturnErrorForUnknownCode(): void + { + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(404); + $response->assertJson([ + 'errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ], + ]); + } + + #[Test] + public function itShouldReturnErrorForInvalidCode(): void + { + Cache::shouldReceive('pull') + ->once() + ->with(hash('xxh3', 'https://app.example.com')) + ->andReturn(['auth_code' => 'some value']); + + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => '123abc', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'code' => [ + 'The code is invalid.', + ], + ], + ]); + } + + #[Test] + public function itShouldReturnErrorForInvalidCodeVerifier(): void + { + Cache::shouldReceive('pull') + ->once() + ->with(hash('xxh3', 'https://app.example.com')) + ->andReturn([ + 'auth_code' => '123456', + 'code_challenge' => '123abc', + ]); + + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', // Matches auth_code we have put in the Cache + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => 'invalid_value', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'code_verifier' => [ + 'The code verifier is invalid.', + ], + ], + ]); + } + + #[Test] + public function itShouldReturnMeDataForValidRequest(): void + { + Cache::shouldReceive('pull') + ->once() + ->with(hash('xxh3', 'https://app.example.com')) + ->andReturn([ + 'auth_code' => '123456', + 'code_challenge' => sodium_bin2base64( + hash('sha256', 'abc123def', true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ), + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + ]); + + $response = $this->post('/auth', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', // Matches auth_code we have put in the Cache + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => 'abc123def', + ]); + + $response->assertStatus(200); + $response->assertJson([ + 'me' => config('app.url'), + ]); + } + + #[Test] + public function itShouldReturnErrorWhenNoScopesGivenToTokenEndpoint(): void + { + Cache::shouldReceive('pull') + ->once() + ->with(hash('xxh3', 'https://app.example.com')) + ->andReturn([ + 'auth_code' => '123456', + 'code_challenge' => sodium_bin2base64( + hash('sha256', 'abc123def', true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ), + 'scopes' => '', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + ]); + + $response = $this->post('/token', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', // Matches auth_code we have put in the Cache + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => 'abc123def', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'scope' => [ + 'The scope property must be non-empty for an access token to be issued.', + ], + ], + ]); + } + + #[Test] + public function itShouldReturnErrorWhenClientIdDoesNotMatchDuringTokenRequest(): void + { + Cache::shouldReceive('pull') + ->once() + ->with(hash('xxh3', 'https://app.example.com')) + ->andReturn([ + 'auth_code' => '123456', + 'code_challenge' => sodium_bin2base64( + hash('sha256', 'abc123def', true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ), + 'scopes' => 'create update', + 'client_id' => 'https://app.example.invalid', + 'redirect_uri' => 'example-app://callback', + ]); + + $response = $this->post('/token', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', // Matches auth_code we have put in the Cache + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => 'abc123def', + ]); + + $response->assertStatus(400); + $response->assertJson([ + 'errors' => [ + 'client_id' => [ + 'The client id is invalid.', + ], + ], + ]); + } + + #[Test] + public function itShouldReturnAnAccessTokenIfValidationPasses(): void + { + Cache::shouldReceive('pull') + ->once() + ->with(hash('xxh3', 'https://app.example.com')) + ->andReturn([ + 'auth_code' => '123456', + 'code_challenge' => sodium_bin2base64( + hash('sha256', 'abc123def', true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING + ), + 'scopes' => 'create update', + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + ]); + + $response = $this->post('/token', [ + 'grant_type' => 'authorization_code', + 'code' => '123456', // Matches auth_code we have put in the Cache + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'example-app://callback', + 'code_verifier' => 'abc123def', + ]); + + $response->assertStatus(200); + $response->assertJson([ + 'token_type' => 'Bearer', + 'scope' => 'create update', + 'me' => config('app.url'), + ]); + } } diff --git a/tests/Feature/TokenEndpointTest.php b/tests/Feature/TokenEndpointTest.php deleted file mode 100644 index 1a6d05c0..00000000 --- a/tests/Feature/TokenEndpointTest.php +++ /dev/null @@ -1,73 +0,0 @@ - config('app.url'), - 'scope' => 'create update', - ], JSON_THROW_ON_ERROR)), - ]); - $handlerStack = HandlerStack::create($mockHandler); - $mockGuzzleClient = new Client(['handler' => $handlerStack]); - $this->app->instance(Client::class, $mockGuzzleClient); - $response = $this->post('/api/token', [ - 'grant_type' => 'authorization_code', - 'code' => '1234567890', - 'redirect_uri' => 'https://example.com/auth/callback', - 'client_id' => 'https://example.com', - 'code_verifier' => '1234567890', - ]); - - $this->assertSame(config('app.url'), $response->json('me')); - $this->assertNotEmpty($response->json('access_token')); - } - - /** - * @test - * - * @throws JsonException - * @throws Exception - */ - public function tokenEndpointReturnsErrorWhenAuthEndpointLacksMeData(): void - { - $mockHandler = new MockHandler([ - new \GuzzleHttp\Psr7\Response(400, [], json_encode([ - 'error' => 'error_message', - ], JSON_THROW_ON_ERROR)), - ]); - $handlerStack = HandlerStack::create($mockHandler); - $mockGuzzleClient = new Client(['handler' => $handlerStack]); - $this->app->instance(Client::class, $mockGuzzleClient); - $response = $this->post('/api/token', [ - 'me' => config('app.url'), - 'code' => 'abc123', - 'redirect_uri' => config('app.url') . '/indieauth-callback', - 'client_id' => config('app.url') . '/micropub-client', - 'state' => random_int(1000, 10000), - ]); - $response->assertStatus(401); - $response->assertJson([ - 'error' => 'There was an error verifying the IndieAuth code', - ]); - } -}