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 { $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', '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 itShouldReturnErrorViewWhenResponeTypeIsWrong(): 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', '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('only a response_type of "code" is supported'); } #[Test] public function itShouldReturnErrorViewWhenResponeTypeIsMissing(): 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', '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('response_type is required'); } #[Test] public function itShouldReturnErrorViewWhenClientIdIsMissing(): 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', '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('client_id is required'); } #[Test] public function itShouldReturnErrorViewWhenRedirectUriIsMissing(): void { $user = User::factory()->make(); $url = url()->query('/auth', [ 'response_type' => 'code', 'me' => 'https://example.com', 'client_id' => 'https://app.example.com', '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 required'); } #[Test] public function itShouldReturnErrorViewWhenStateIsMissing(): 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', '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('state is required'); } #[Test] public function itShouldReturnErrorViewWhenCodeChallengeIsMissing(): 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', 'scopes' => '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 itShouldReturnErrorViewWhenCodeChallengeMethodIsMissing(): 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', 'scopes' => '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 itShouldReturnErrorViewWhenCodeChallengeMethodIsUnsupportedValue(): 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', 'scopes' => '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 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'), ]); } }