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 it_should_require_admin_login_to_show_authorise_form(): 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', 'scope' => '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 it_should_return_approval_view_when_the_request_is_valid(): void { $user = User::factory()->make(); $url = url()->query('/auth', [ 'response_type' => 'code', 'me' => 'https://example.com', 'client_id' => 'https://app.example.com', 'redirect_uri' => 'https://app.example.com/callback', 'state' => '123456', 'scope' => '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 it_should_return_error_view_when_respone_type_is_wrong(): void { $user = User::factory()->make(); $url = url()->query('/auth', [ 'response_type' => 'invalid_value', 'me' => 'https://example.com', 'client_id' => 'https://app.example.com', 'redirect_uri' => 'https://app.example.com/callback', 'state' => '123456', 'scope' => '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('only a response_type of "code" is supported'); } #[Test] public function it_should_return_error_view_when_respone_type_is_missing(): void { $user = User::factory()->make(); $url = url()->query('/auth', [ 'me' => 'https://example.com', 'client_id' => 'https://app.example.com', 'redirect_uri' => 'https://app.example.com/callback', 'state' => '123456', 'scope' => '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('response_type is required'); } #[Test] public function it_should_return_error_view_when_client_id_is_missing(): void { $user = User::factory()->make(); $url = url()->query('/auth', [ 'response_type' => 'code', 'me' => 'https://example.com', 'redirect_uri' => 'https://app.example.com/callback', 'state' => '123456', 'scope' => '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('client_id is required'); } #[Test] public function it_should_return_error_view_when_redirect_uri_is_missing(): void { $user = User::factory()->make(); $url = url()->query('/auth', [ 'response_type' => 'code', 'me' => 'https://example.com', 'client_id' => 'https://app.example.com', 'state' => '123456', 'scope' => '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 required'); } #[Test] public function it_should_return_error_view_when_state_is_missing(): void { $user = User::factory()->make(); $url = url()->query('/auth', [ 'response_type' => 'code', 'me' => 'https://example.com', 'client_id' => 'https://app.example.com', 'redirect_uri' => 'https://app.example.com/callback', 'scope' => '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('state is required'); } #[Test] public function it_should_return_error_view_when_code_challenge_is_missing(): void { $user = User::factory()->make(); $url = url()->query('/auth', [ 'response_type' => 'code', 'me' => 'https://example.com', 'client_id' => 'https://app.example.com', 'redirect_uri' => 'https://app.example.com/callback', 'state' => '123456', 'scope' => 'create update delete', 'code_challenge_method' => 'S256', ]); $response = $this->actingAs($user)->get($url); $response->assertStatus(200); $response->assertViewIs('indieauth.error'); $response->assertSee('code_challenge is required'); } #[Test] public function it_should_return_error_view_when_code_challenge_method_is_missing(): void { $user = User::factory()->make(); $url = url()->query('/auth', [ 'response_type' => 'code', 'me' => 'https://example.com', 'client_id' => 'https://app.example.com', 'redirect_uri' => 'https://app.example.com/callback', 'state' => '123456', 'scope' => 'create update delete', 'code_challenge' => '123456', ]); $response = $this->actingAs($user)->get($url); $response->assertStatus(200); $response->assertViewIs('indieauth.error'); $response->assertSee('code_challenge_method is required'); } #[Test] public function it_should_return_error_view_when_code_challenge_method_is_unsupported_value(): void { $user = User::factory()->make(); $url = url()->query('/auth', [ 'response_type' => 'code', 'me' => 'https://example.com', 'client_id' => 'https://app.example.com', 'redirect_uri' => 'https://app.example.com/callback', 'state' => '123456', 'scope' => 'create update delete', 'code_challenge' => '123456', 'code_challenge_method' => 'invalid_value', ]); $response = $this->actingAs($user)->get($url); $response->assertStatus(200); $response->assertViewIs('indieauth.error'); $response->assertSee('only a code_challenge_method of "S256" is supported'); } #[Test] public function it_should_check_client_id_for_valid_redirect(): 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', 'scope' => '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 it_should_error_if_client_id_page_has_no_valid_redirect(): 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', 'scope' => '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 it_should_redirect_to_app_on_approval(): 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 it_should_show_error_response_when_approval_request_is_missing_grant_type(): 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 it_should_show_error_response_when_approval_request_is_missing_code(): 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 it_should_show_error_response_when_approval_request_is_missing_client_id(): 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 it_should_show_error_response_when_approval_request_is_missing_redirect_uri(): 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 it_should_show_error_response_when_approval_request_is_missing_code_verifier(): 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 it_should_show_error_response_when_approval_request_grant_type_is_unsupported(): 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 it_should_return_error_for_unknown_code(): 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 it_should_return_error_for_invalid_code(): 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 it_should_return_error_for_invalid_code_verifier(): 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 it_should_return_me_data_for_valid_request(): 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 it_should_return_error_when_no_scopes_given_to_token_endpoint(): 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 ), 'scope' => '', '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 it_should_return_error_when_client_id_does_not_match_during_token_request(): 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 ), 'scope' => '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 it_should_return_an_access_token_if_validation_passes(): 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 ), 'scope' => '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'), ]); } }