diff --git a/database/factories/ClientFactory.php b/database/factories/ClientFactory.php index 6a6c3e3c..76aae764 100644 --- a/database/factories/ClientFactory.php +++ b/database/factories/ClientFactory.php @@ -38,6 +38,16 @@ public function definition(): array ]; } + /** + * Use as a public client. + */ + public function asPublic(): static + { + return $this->state([ + 'secret' => null, + ]); + } + /** * Use as a Password client. */ @@ -67,6 +77,7 @@ public function asImplicitClient(): static { return $this->state([ 'grant_types' => ['implicit'], + 'secret' => null, ]); } diff --git a/src/Exceptions/OAuthServerException.php b/src/Exceptions/OAuthServerException.php index 31d7cca9..738b5e5e 100644 --- a/src/Exceptions/OAuthServerException.php +++ b/src/Exceptions/OAuthServerException.php @@ -3,16 +3,65 @@ namespace Laravel\Passport\Exceptions; use Illuminate\Http\Exceptions\HttpResponseException; -use Illuminate\Http\Response; +use Illuminate\Support\Arr; +use Laravel\Passport\Http\Controllers\ConvertsPsrResponses; use League\OAuth2\Server\Exception\OAuthServerException as LeagueException; +use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface; +use Nyholm\Psr7\Response as Psr7Response; class OAuthServerException extends HttpResponseException { + use ConvertsPsrResponses; + /** * Create a new OAuthServerException. */ - public function __construct(LeagueException $e, Response $response) + public function __construct(LeagueException $e, bool $useFragment = false) + { + parent::__construct($this->convertResponse($e->generateHttpResponse(new Psr7Response, $useFragment)), $e); + } + + /** + * Create a new OAuthServerException for when login is required. + */ + public static function loginRequired(AuthorizationRequestInterface $authRequest): static { - parent::__construct($response, $e); + $exception = new LeagueException( + 'The authorization server requires end-user authentication.', + 9, + 'login_required', + 401, + 'The user is not authenticated', + $authRequest->getRedirectUri() ?? Arr::wrap($authRequest->getClient()->getRedirectUri())[0] + ); + + $exception->setPayload([ + 'state' => $authRequest->getState(), + ...$exception->getPayload(), + ]); + + return new static($exception, $authRequest->getGrantTypeId() === 'implicit'); + } + + /** + * Create a new OAuthServerException for when consent is required. + */ + public static function consentRequired(AuthorizationRequestInterface $authRequest): static + { + $exception = new LeagueException( + 'The authorization server requires end-user consent.', + 9, + 'consent_required', + 401, + null, + $authRequest->getRedirectUri() ?? Arr::wrap($authRequest->getClient()->getRedirectUri())[0] + ); + + $exception->setPayload([ + 'state' => $authRequest->getState(), + ...$exception->getPayload(), + ]); + + return new static($exception, $authRequest->getGrantTypeId() === 'implicit'); } } diff --git a/src/Http/Controllers/ApproveAuthorizationController.php b/src/Http/Controllers/ApproveAuthorizationController.php index 385f7fbc..736a8712 100644 --- a/src/Http/Controllers/ApproveAuthorizationController.php +++ b/src/Http/Controllers/ApproveAuthorizationController.php @@ -30,6 +30,6 @@ public function approve(Request $request): Response return $this->withErrorHandling(fn () => $this->convertResponse( $this->server->completeAuthorizationRequest($authRequest, new Psr7Response) - )); + ), $authRequest->getGrantTypeId() === 'implicit'); } } diff --git a/src/Http/Controllers/AuthorizationController.php b/src/Http/Controllers/AuthorizationController.php index 396b5966..f816f26e 100644 --- a/src/Http/Controllers/AuthorizationController.php +++ b/src/Http/Controllers/AuthorizationController.php @@ -13,10 +13,10 @@ use Laravel\Passport\ClientRepository; use Laravel\Passport\Contracts\AuthorizationViewResponse; use Laravel\Passport\Exceptions\AuthenticationException; +use Laravel\Passport\Exceptions\OAuthServerException; use Laravel\Passport\Passport; use League\OAuth2\Server\AuthorizationServer; use League\OAuth2\Server\Entities\ScopeEntityInterface; -use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface; use Nyholm\Psr7\Response as Psr7Response; use Psr\Http\Message\ServerRequestInterface; @@ -41,14 +41,15 @@ public function __construct( */ public function authorize(ServerRequestInterface $psrRequest, Request $request): Response|AuthorizationViewResponse { - $authRequest = $this->withErrorHandling(fn () => $this->server->validateAuthorizationRequest($psrRequest)); + $authRequest = $this->withErrorHandling( + fn () => $this->server->validateAuthorizationRequest($psrRequest), + ($psrRequest->getQueryParams()['response_type'] ?? null) === 'token' + ); if ($this->guard->guest()) { - if ($request->get('prompt') === 'none') { - return $this->denyRequest($authRequest); - } - - $this->promptForLogin($request); + $request->get('prompt') === 'none' + ? throw OAuthServerException::loginRequired($authRequest) + : $this->promptForLogin($request); } if ($request->get('prompt') === 'login' && @@ -62,17 +63,19 @@ public function authorize(ServerRequestInterface $psrRequest, Request $request): $request->session()->forget('promptedForLogin'); - $scopes = $this->parseScopes($authRequest); $user = $this->guard->user(); + $authRequest->setUser(new User($user->getAuthIdentifier())); + + $scopes = $this->parseScopes($authRequest); $client = $this->clients->find($authRequest->getClient()->getIdentifier()); if ($request->get('prompt') !== 'consent' && ($client->skipsAuthorization($user, $scopes) || $this->hasGrantedScopes($user, $client, $scopes))) { - return $this->approveRequest($authRequest, $user); + return $this->approveRequest($authRequest); } if ($request->get('prompt') === 'none') { - return $this->denyRequest($authRequest, $user); + throw OAuthServerException::consentRequired($authRequest); } $request->session()->put('authToken', $authToken = Str::random()); @@ -121,44 +124,13 @@ protected function hasGrantedScopes(Authenticatable $user, Client $client, array /** * Approve the authorization request. */ - protected function approveRequest(AuthorizationRequestInterface $authRequest, Authenticatable $user): Response + protected function approveRequest(AuthorizationRequestInterface $authRequest): Response { - $authRequest->setUser(new User($user->getAuthIdentifier())); - $authRequest->setAuthorizationApproved(true); return $this->withErrorHandling(fn () => $this->convertResponse( $this->server->completeAuthorizationRequest($authRequest, new Psr7Response) - )); - } - - /** - * Deny the authorization request. - */ - protected function denyRequest(AuthorizationRequestInterface $authRequest, ?Authenticatable $user = null): Response - { - if (is_null($user)) { - $uri = $authRequest->getRedirectUri() - ?? (is_array($authRequest->getClient()->getRedirectUri()) - ? $authRequest->getClient()->getRedirectUri()[0] - : $authRequest->getClient()->getRedirectUri()); - - $separator = $authRequest->getGrantTypeId() === 'implicit' ? '#' : '?'; - - $uri = $uri.(str_contains($uri, $separator) ? '&' : $separator).'state='.$authRequest->getState(); - - return $this->withErrorHandling(function () use ($uri) { - throw OAuthServerException::accessDenied('Unauthenticated', $uri); - }); - } - - $authRequest->setUser(new User($user->getAuthIdentifier())); - - $authRequest->setAuthorizationApproved(false); - - return $this->withErrorHandling(fn () => $this->convertResponse( - $this->server->completeAuthorizationRequest($authRequest, new Psr7Response) - )); + ), $authRequest->getGrantTypeId() === 'implicit'); } /** diff --git a/src/Http/Controllers/DenyAuthorizationController.php b/src/Http/Controllers/DenyAuthorizationController.php index 0c2b0154..abe7f67b 100644 --- a/src/Http/Controllers/DenyAuthorizationController.php +++ b/src/Http/Controllers/DenyAuthorizationController.php @@ -30,6 +30,6 @@ public function deny(Request $request): Response return $this->withErrorHandling(fn () => $this->convertResponse( $this->server->completeAuthorizationRequest($authRequest, new Psr7Response) - )); + ), $authRequest->getGrantTypeId() === 'implicit'); } } diff --git a/src/Http/Controllers/HandlesOAuthErrors.php b/src/Http/Controllers/HandlesOAuthErrors.php index 8c1ba18d..26bebf0f 100644 --- a/src/Http/Controllers/HandlesOAuthErrors.php +++ b/src/Http/Controllers/HandlesOAuthErrors.php @@ -5,12 +5,9 @@ use Closure; use Laravel\Passport\Exceptions\OAuthServerException; use League\OAuth2\Server\Exception\OAuthServerException as LeagueException; -use Nyholm\Psr7\Response as Psr7Response; trait HandlesOAuthErrors { - use ConvertsPsrResponses; - /** * Perform the given callback with exception handling. * @@ -21,15 +18,12 @@ trait HandlesOAuthErrors * * @throws \Laravel\Passport\Exceptions\OAuthServerException */ - protected function withErrorHandling(Closure $callback) + protected function withErrorHandling(Closure $callback, bool $useFragment = false) { try { return $callback(); } catch (LeagueException $e) { - throw new OAuthServerException( - $e, - $this->convertResponse($e->generateHttpResponse(new Psr7Response)) - ); + throw new OAuthServerException($e, $useFragment); } } } diff --git a/src/Http/Controllers/RetrievesAuthRequestFromSession.php b/src/Http/Controllers/RetrievesAuthRequestFromSession.php index 99b0e850..47d59c09 100644 --- a/src/Http/Controllers/RetrievesAuthRequestFromSession.php +++ b/src/Http/Controllers/RetrievesAuthRequestFromSession.php @@ -4,7 +4,6 @@ use Exception; use Illuminate\Http\Request; -use Laravel\Passport\Bridge\User; use Laravel\Passport\Exceptions\InvalidAuthTokenException; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; @@ -18,18 +17,14 @@ trait RetrievesAuthRequestFromSession */ protected function getAuthRequestFromSession(Request $request): AuthorizationRequest { - if ($request->isNotFilled('auth_token') || $request->session()->pull('authToken') !== $request->get('auth_token')) { + if ($request->isNotFilled('auth_token') || + $request->session()->pull('authToken') !== $request->get('auth_token')) { $request->session()->forget(['authToken', 'authRequest']); throw InvalidAuthTokenException::different(); } - return tap($request->session()->pull('authRequest'), function ($authRequest) use ($request) { - if (! $authRequest) { - throw new Exception('Authorization request was not present in the session.'); - } - - $authRequest->setUser(new User($request->user()->getAuthIdentifier())); - }); + return $request->session()->pull('authRequest') + ?? throw new Exception('Authorization request was not present in the session.'); } } diff --git a/tests/Feature/AuthorizationCodeGrantTest.php b/tests/Feature/AuthorizationCodeGrantTest.php index cb76f463..6f297849 100644 --- a/tests/Feature/AuthorizationCodeGrantTest.php +++ b/tests/Feature/AuthorizationCodeGrantTest.php @@ -54,7 +54,7 @@ public function testIssueAccessToken() $response = $this->post('/oauth/authorize', ['auth_token' => $json['authToken']]); $response->assertRedirect(); - $response->assertSessionMissing(['deviceCode', 'authToken']); + $response->assertSessionMissing(['authRequest', 'authToken']); $location = $response->headers->get('Location'); parse_str(parse_url($location, PHP_URL_QUERY), $params); @@ -106,7 +106,7 @@ public function testDenyAuthorization() $response = $this->delete('/oauth/authorize', ['auth_token' => $json['authToken']]); $response->assertRedirect(); - $response->assertSessionMissing(['deviceCode', 'authToken']); + $response->assertSessionMissing(['authRequest', 'authToken']); $location = $response->headers->get('Location'); parse_str(parse_url($location, PHP_URL_QUERY), $params); @@ -180,6 +180,30 @@ public function testValidateAuthorizationRequest() $this->assertArrayHasKey('error_description', $json); } + public function testValidateScopes() + { + $client = ClientFactory::new()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'code', + 'scope' => 'foo', + 'state' => $state = Str::random(40), + ]); + + $response = $this->get('/oauth/authorize?'.$query); + $response->assertRedirect(); + + $location = $response->headers->get('Location'); + parse_str(parse_url($location, PHP_URL_QUERY), $params); + + $this->assertStringStartsWith($redirect.'?', $location); + // $this->assertSame($state, $params['state']); + $this->assertSame('invalid_scope', $params['error']); + $this->assertArrayHasKey('error_description', $params); + } + public function testRedirectGuestUser() { Route::get('/foo', fn () => '')->name('login'); @@ -218,7 +242,100 @@ public function testPromptNone() $this->assertStringStartsWith($redirect.'?', $location); $this->assertSame($state, $params['state']); - $this->assertSame('access_denied', $params['error']); + $this->assertSame('consent_required', $params['error']); + $this->assertArrayHasKey('error_description', $params); + } + + public function testPromptNoneLoginRequired() + { + $client = ClientFactory::new()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'code', + 'state' => $state = Str::random(40), + 'prompt' => 'none', + ]); + + $response = $this->get('/oauth/authorize?'.$query); + $response->assertRedirect(); + + $location = $response->headers->get('Location'); + parse_str(parse_url($location, PHP_URL_QUERY), $params); + + $this->assertStringStartsWith($redirect.'?', $location); + $this->assertSame($state, $params['state']); + $this->assertSame('login_required', $params['error']); $this->assertArrayHasKey('error_description', $params); } + + public function testPromptConsent() + { + $client = ClientFactory::new()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'code', + 'scope' => 'create read update', + 'state' => Str::random(40), + ]); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + $json = $this->get('/oauth/authorize?'.$query)->json(); + + $response = $this->post('/oauth/authorize', ['auth_token' => $json['authToken']]); + parse_str(parse_url($response->headers->get('Location'), PHP_URL_QUERY), $params); + + $this->post('/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + 'redirect_uri' => $redirect, + 'code' => $params['code'], + ]); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect, + 'response_type' => 'code', + 'scope' => 'create read', + 'state' => Str::random(40), + 'prompt' => 'consent', + ]); + + $response = $this->get('/oauth/authorize?'.$query); + + $response->assertOk(); + $response->assertSessionHas('authRequest'); + $response->assertSessionHas('authToken'); + $json = $response->json(); + $this->assertEqualsCanonicalizing(['client', 'user', 'scopes', 'request', 'authToken'], array_keys($json)); + $this->assertSame(collect(Passport::scopesFor(['create', 'read']))->toArray(), $json['scopes']); + } + + public function testPromptLogin() + { + Route::get('/foo', fn () => '')->name('login'); + + $client = ClientFactory::new()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $client->redirect_uris[0], + 'response_type' => 'code', + 'scope' => 'create read update', + 'state' => Str::random(40), + 'prompt' => 'login', + ]); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + $response = $this->get('/oauth/authorize?'.$query); + + $response->assertSessionHas('promptedForLogin', true); + $response->assertRedirectToRoute('login'); + } } diff --git a/tests/Feature/AuthorizationCodeGrantWithPkceTest.php b/tests/Feature/AuthorizationCodeGrantWithPkceTest.php new file mode 100644 index 00000000..559a4084 --- /dev/null +++ b/tests/Feature/AuthorizationCodeGrantWithPkceTest.php @@ -0,0 +1,117 @@ + 'Create', + 'read' => 'Read', + 'update' => 'Update', + 'delete' => 'Delete', + ]); + + Passport::authorizationView(fn ($params) => $params); + } + + public function testIssueAccessToken() + { + $client = ClientFactory::new()->asPublic()->create(); + + $codeVerifier = Str::random(128); + $codeChallenge = strtr(rtrim(base64_encode(hash('sha256', $codeVerifier, true)), '='), '+/', '-_'); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'code', + 'scope' => 'create read', + 'state' => $state = Str::random(40), + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => 'S256', + ]); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + + $response = $this->get('/oauth/authorize?'.$query); + + $response->assertOk(); + $response->assertSessionHas('authRequest'); + $response->assertSessionHas('authToken'); + $json = $response->json(); + $this->assertEqualsCanonicalizing(['client', 'user', 'scopes', 'request', 'authToken'], array_keys($json)); + $this->assertSame(collect(Passport::scopesFor(['create', 'read']))->toArray(), $json['scopes']); + + $response = $this->post('/oauth/authorize', ['auth_token' => $json['authToken']]); + $response->assertRedirect(); + $response->assertSessionMissing(['authRequest', 'authToken']); + + $location = $response->headers->get('Location'); + parse_str(parse_url($location, PHP_URL_QUERY), $params); + + $this->assertStringStartsWith($redirect.'?', $location); + $this->assertSame($state, $params['state']); + $this->assertArrayHasKey('code', $params); + + $response = $this->post('/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect, + 'code' => $params['code'], + 'code_verifier' => $codeVerifier, + ]); + + $response->assertOk(); + $json = $response->json(); + $this->assertArrayHasKey('access_token', $json); + $this->assertArrayHasKey('refresh_token', $json); + $this->assertSame('Bearer', $json['token_type']); + $this->assertSame(31536000, $json['expires_in']); + + Route::get('/foo', fn (Request $request) => $request->user()->token()->toJson()) + ->middleware('auth:api'); + + $json = $this->withToken($json['access_token'], $json['token_type'])->get('/foo')->json(); + + $this->assertSame($client->getKey(), $json['oauth_client_id']); + $this->assertEquals($user->getAuthIdentifier(), $json['oauth_user_id']); + $this->assertSame(['create', 'read'], $json['oauth_scopes']); + } + + public function testRequireCodeChallenge() + { + $client = ClientFactory::new()->asPublic()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $client->redirect_uris[0], + 'response_type' => 'code', + ]); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + $response = $this->get('/oauth/authorize?'.$query); + + $response->assertStatus(400); + $json = $response->json(); + + $this->assertSame('invalid_request', $json['error']); + $this->assertSame('Code challenge must be provided for public clients', $json['hint']); + $this->assertArrayHasKey('error_description', $json); + } +} diff --git a/tests/Feature/ImplicitGrantTest.php b/tests/Feature/ImplicitGrantTest.php new file mode 100644 index 00000000..aa23a407 --- /dev/null +++ b/tests/Feature/ImplicitGrantTest.php @@ -0,0 +1,322 @@ + 'Create', + 'read' => 'Read', + 'update' => 'Update', + 'delete' => 'Delete', + ]); + + Passport::authorizationView(fn ($params) => $params); + } + + public function testIssueAccessToken() + { + $client = ClientFactory::new()->asImplicitClient()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'token', + 'scope' => 'create read', + 'state' => $state = Str::random(40), + ]); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + $response = $this->get('/oauth/authorize?'.$query); + + $response->assertOk(); + $response->assertSessionHas('authRequest'); + $response->assertSessionHas('authToken'); + $json = $response->json(); + $this->assertEqualsCanonicalizing(['client', 'user', 'scopes', 'request', 'authToken'], array_keys($json)); + $this->assertSame(collect(Passport::scopesFor(['create', 'read']))->toArray(), $json['scopes']); + + $response = $this->post('/oauth/authorize', ['auth_token' => $json['authToken']]); + $response->assertRedirect(); + $response->assertSessionMissing(['authRequest', 'authToken']); + + $location = $response->headers->get('Location'); + parse_str(parse_url($location, PHP_URL_FRAGMENT), $params); + + $this->assertStringStartsWith($redirect.'#', $location); + $this->assertSame($state, $params['state']); + $this->assertArrayHasKey('access_token', $params); + $this->assertArrayNotHasKey('refresh_token', $params); + $this->assertSame('Bearer', $params['token_type']); + $this->assertSame('31536000', $params['expires_in']); + + Route::get('/foo', fn (Request $request) => $request->user()->token()->toJson()) + ->middleware('auth:api'); + + $json = $this->withToken($params['access_token'], $params['token_type'])->get('/foo')->json(); + + $this->assertSame($client->getKey(), $json['oauth_client_id']); + $this->assertEquals($user->getAuthIdentifier(), $json['oauth_user_id']); + $this->assertSame(['create', 'read'], $json['oauth_scopes']); + } + + public function testDenyAuthorization() + { + $client = ClientFactory::new()->asImplicitClient()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'token', + 'scope' => 'create read', + 'state' => $state = Str::random(40), + ]); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + $json = $this->get('/oauth/authorize?'.$query)->json(); + + $response = $this->delete('/oauth/authorize', ['auth_token' => $json['authToken']]); + $response->assertRedirect(); + $response->assertSessionMissing(['authRequest', 'authToken']); + + $location = $response->headers->get('Location'); + parse_str(parse_url($location, PHP_URL_FRAGMENT), $params); + + // $this->assertStringStartsWith($redirect.'#', $location); + // $this->assertSame($state, $params['state']); + $this->assertSame('access_denied', $params['error']); + $this->assertArrayHasKey('error_description', $params); + } + + public function testSkipsAuthorizationWhenHasGrantedScopes() + { + $client = ClientFactory::new()->asImplicitClient()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'token', + 'scope' => 'create read', + 'state' => $state = Str::random(40), + ]); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + $json = $this->get('/oauth/authorize?'.$query)->json(); + + $response = $this->post('/oauth/authorize', ['auth_token' => $json['authToken']]); + $response->assertRedirect(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect, + 'response_type' => 'token', + 'scope' => 'create', + 'state' => $state = Str::random(40), + ]); + + $response = $this->get('/oauth/authorize?'.$query); + $response->assertRedirect(); + $response->assertSessionMissing(['authRequest', 'authToken']); + + $location = $response->headers->get('Location'); + parse_str(parse_url($location, PHP_URL_FRAGMENT), $params); + + $this->assertStringStartsWith($redirect.'#', $location); + $this->assertSame($state, $params['state']); + $this->assertArrayHasKey('access_token', $params); + $this->assertArrayNotHasKey('refresh_token', $params); + $this->assertSame('Bearer', $params['token_type']); + $this->assertSame('31536000', $params['expires_in']); + } + + public function testValidateAuthorizationRequest() + { + $client = ClientFactory::new()->asImplicitClient()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => fake()->url(), + 'response_type' => 'token', + ]); + + $json = $this->get('/oauth/authorize?'.$query)->json(); + $this->assertSame('invalid_client', $json['error']); + $this->assertArrayHasKey('error_description', $json); + } + + public function testValidateScopes() + { + $client = ClientFactory::new()->asImplicitClient()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'token', + 'scope' => 'foo', + 'state' => $state = Str::random(40), + ]); + + $response = $this->get('/oauth/authorize?'.$query); + $response->assertRedirect(); + + $location = $response->headers->get('Location'); + parse_str(parse_url($location, PHP_URL_FRAGMENT), $params); + + $this->assertStringStartsWith($redirect.'#', $location); + // $this->assertSame($state, $params['state']); + $this->assertSame('invalid_scope', $params['error']); + $this->assertArrayHasKey('error_description', $params); + } + + public function testRedirectGuestUser() + { + Route::get('/foo', fn () => '')->name('login'); + + $client = ClientFactory::new()->asImplicitClient()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $client->redirect_uris[0], + 'response_type' => 'token', + ]); + + $response = $this->get('/oauth/authorize?'.$query); + $response->assertSessionHas('promptedForLogin', true); + $response->assertRedirectToRoute('login'); + } + + public function testPromptNone() + { + $client = ClientFactory::new()->asImplicitClient()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'token', + 'state' => $state = Str::random(40), + 'prompt' => 'none', + ]); + + $this->actingAs(UserFactory::new()->create(), 'web'); + $response = $this->get('/oauth/authorize?'.$query); + $response->assertRedirect(); + + $location = $response->headers->get('Location'); + parse_str(parse_url($location, PHP_URL_FRAGMENT), $params); + + $this->assertStringStartsWith($redirect.'#', $location); + $this->assertSame($state, $params['state']); + $this->assertSame('consent_required', $params['error']); + $this->assertArrayHasKey('error_description', $params); + } + + public function testPromptNoneLoginRequired() + { + $client = ClientFactory::new()->asImplicitClient()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'token', + 'state' => $state = Str::random(40), + 'prompt' => 'none', + ]); + + $response = $this->get('/oauth/authorize?'.$query); + $response->assertRedirect(); + + $location = $response->headers->get('Location'); + parse_str(parse_url($location, PHP_URL_FRAGMENT), $params); + + $this->assertStringStartsWith($redirect.'#', $location); + $this->assertSame($state, $params['state']); + $this->assertSame('login_required', $params['error']); + $this->assertArrayHasKey('error_description', $params); + } + + public function testPromptConsent() + { + $client = ClientFactory::new()->asImplicitClient()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect = $client->redirect_uris[0], + 'response_type' => 'token', + 'scope' => 'create read update', + 'state' => Str::random(40), + ]); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + $json = $this->get('/oauth/authorize?'.$query)->json(); + + $response = $this->post('/oauth/authorize', ['auth_token' => $json['authToken']]); + $response->assertRedirect(); + + $location = $response->headers->get('Location'); + parse_str(parse_url($location, PHP_URL_FRAGMENT), $params); + + $this->assertStringStartsWith($redirect.'#', $location); + $this->assertArrayHasKey('access_token', $params); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $redirect, + 'response_type' => 'token', + 'scope' => 'create read', + 'state' => Str::random(40), + 'prompt' => 'consent', + ]); + + $response = $this->get('/oauth/authorize?'.$query); + + $response->assertOk(); + $response->assertSessionHas('authRequest'); + $response->assertSessionHas('authToken'); + $json = $response->json(); + $this->assertEqualsCanonicalizing(['client', 'user', 'scopes', 'request', 'authToken'], array_keys($json)); + $this->assertSame(collect(Passport::scopesFor(['create', 'read']))->toArray(), $json['scopes']); + } + + public function testPromptLogin() + { + Route::get('/foo', fn () => '')->name('login'); + + $client = ClientFactory::new()->asImplicitClient()->create(); + + $query = http_build_query([ + 'client_id' => $client->getKey(), + 'redirect_uri' => $client->redirect_uris[0], + 'response_type' => 'token', + 'scope' => 'create read update', + 'state' => Str::random(40), + 'prompt' => 'login', + ]); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + $response = $this->get('/oauth/authorize?'.$query); + + $response->assertSessionHas('promptedForLogin', true); + $response->assertRedirectToRoute('login'); + } +} diff --git a/tests/Unit/ApproveAuthorizationControllerTest.php b/tests/Unit/ApproveAuthorizationControllerTest.php index 646ee555..1e9e0b5b 100644 --- a/tests/Unit/ApproveAuthorizationControllerTest.php +++ b/tests/Unit/ApproveAuthorizationControllerTest.php @@ -35,9 +35,7 @@ public function test_complete_authorization_request() $request->shouldReceive('user')->andReturn(new ApproveAuthorizationControllerFakeUser); - $authRequest->shouldReceive('getClient->getIdentifier')->andReturn(1); - $authRequest->shouldReceive('getUser->getIdentifier')->andReturn(2); - $authRequest->shouldReceive('setUser')->once(); + $authRequest->shouldReceive('getGrantTypeId')->once()->andReturn('authorization_code'); $authRequest->shouldReceive('setAuthorizationApproved')->once()->with(true); $psrResponse = new Response(); diff --git a/tests/Unit/AuthorizationControllerTest.php b/tests/Unit/AuthorizationControllerTest.php index 0dff23dd..952f337c 100644 --- a/tests/Unit/AuthorizationControllerTest.php +++ b/tests/Unit/AuthorizationControllerTest.php @@ -42,6 +42,9 @@ public function test_authorization_view_is_presented() $guard->shouldReceive('user')->andReturn($user = m::mock(Authenticatable::class)); $server->shouldReceive('validateAuthorizationRequest')->andReturn($authRequest = m::mock(AuthorizationRequestInterface::class)); + $psrRequest = m::mock(ServerRequestInterface::class); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $request = m::mock(Request::class); $request->shouldReceive('session')->andReturn($session = m::mock()); $session->shouldReceive('put')->withSomeOfArgs('authToken'); @@ -51,6 +54,7 @@ public function test_authorization_view_is_presented() $authRequest->shouldReceive('getClient->getIdentifier')->andReturn(1); $authRequest->shouldReceive('getScopes')->andReturn([new Scope('scope-1')]); + $authRequest->shouldReceive('setUser')->once(); $clients = m::mock(ClientRepository::class); $clients->shouldReceive('find')->with(1)->andReturn($client = m::mock(Client::class)); @@ -70,9 +74,7 @@ public function test_authorization_view_is_presented() $controller = new AuthorizationController($server, $guard, $response, $clients); - $this->assertSame($response, $controller->authorize( - m::mock(ServerRequestInterface::class), $request - )); + $this->assertSame($response, $controller->authorize($psrRequest, $request)); } public function test_authorization_exceptions_are_handled() @@ -84,8 +86,10 @@ public function test_authorization_exceptions_are_handled() $guard->shouldReceive('guest')->andReturn(false); $server->shouldReceive('validateAuthorizationRequest')->andThrow(LeagueException::invalidCredentials()); + $psrRequest = m::mock(ServerRequestInterface::class); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $request = m::mock(Request::class); - $request->shouldReceive('session')->andReturn($session = m::mock()); $clients = m::mock(ClientRepository::class); @@ -93,9 +97,7 @@ public function test_authorization_exceptions_are_handled() $controller = new AuthorizationController($server, $guard, $response, $clients); - $controller->authorize( - m::mock(ServerRequestInterface::class), $request - ); + $controller->authorize($psrRequest, $request); } public function test_request_is_approved_if_valid_token_exists() @@ -118,6 +120,9 @@ public function test_request_is_approved_if_valid_token_exists() ->with($authRequest, m::type(ResponseInterface::class)) ->andReturn($psrResponse); + $psrRequest = m::mock(ServerRequestInterface::class); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $request = m::mock(Request::class); $request->shouldReceive('session')->andReturn($session = m::mock()); $session->shouldReceive('forget')->with('promptedForLogin')->once(); @@ -129,6 +134,7 @@ public function test_request_is_approved_if_valid_token_exists() $authRequest->shouldReceive('getScopes')->once()->andReturn([new Scope('scope-1')]); $authRequest->shouldReceive('setUser')->once()->andReturnNull(); $authRequest->shouldReceive('setAuthorizationApproved')->once()->with(true); + $authRequest->shouldReceive('getGrantTypeId')->once()->andReturn('authorization_code'); $clients = m::mock(ClientRepository::class); $clients->shouldReceive('find')->with(1)->andReturn($client = m::mock(Client::class)); @@ -139,9 +145,7 @@ public function test_request_is_approved_if_valid_token_exists() $controller = new AuthorizationController($server, $guard, $response, $clients); - $this->assertSame('approved', $controller->authorize( - m::mock(ServerRequestInterface::class), $request - )->getContent()); + $this->assertSame('approved', $controller->authorize($psrRequest, $request)->getContent()); } public function test_request_is_approved_if_client_can_skip_authorization() @@ -164,6 +168,9 @@ public function test_request_is_approved_if_client_can_skip_authorization() ->with($authRequest, m::type(ResponseInterface::class)) ->andReturn($psrResponse); + $psrRequest = m::mock(ServerRequestInterface::class); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $request = m::mock(Request::class); $request->shouldReceive('session')->andReturn($session = m::mock()); $session->shouldReceive('forget')->with('promptedForLogin')->once(); @@ -175,6 +182,7 @@ public function test_request_is_approved_if_client_can_skip_authorization() $authRequest->shouldReceive('getScopes')->once()->andReturn([new Scope('scope-1')]); $authRequest->shouldReceive('setUser')->once()->andReturnNull(); $authRequest->shouldReceive('setAuthorizationApproved')->once()->with(true); + $authRequest->shouldReceive('getGrantTypeId')->once()->andReturn('authorization_code'); $clients = m::mock(ClientRepository::class); $clients->shouldReceive('find')->with(1)->andReturn($client = m::mock(Client::class)); @@ -183,9 +191,7 @@ public function test_request_is_approved_if_client_can_skip_authorization() $controller = new AuthorizationController($server, $guard, $response, $clients); - $this->assertSame('approved', $controller->authorize( - m::mock(ServerRequestInterface::class), $request - )->getContent()); + $this->assertSame('approved', $controller->authorize($psrRequest, $request)->getContent()); } public function test_authorization_view_is_presented_if_request_has_prompt_equals_to_consent() @@ -199,10 +205,14 @@ public function test_authorization_view_is_presented_if_request_has_prompt_equal $guard = m::mock(StatefulGuard::class); $guard->shouldReceive('guest')->andReturn(false); - $guard->shouldReceive('user')->andReturn($user = m::mock()); + $guard->shouldReceive('user')->andReturn($user = m::mock(Authenticatable::class)); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); $server->shouldReceive('validateAuthorizationRequest') ->andReturn($authRequest = m::mock(AuthorizationRequest::class)); + $psrRequest = m::mock(ServerRequestInterface::class); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $request = m::mock(Request::class); $request->shouldReceive('session')->andReturn($session = m::mock()); $session->shouldReceive('put')->withSomeOfArgs('authToken'); @@ -212,6 +222,7 @@ public function test_authorization_view_is_presented_if_request_has_prompt_equal $authRequest->shouldReceive('getClient->getIdentifier')->once()->andReturn(1); $authRequest->shouldReceive('getScopes')->once()->andReturn([new Scope('scope-1')]); + $authRequest->shouldReceive('setUser')->once()->andReturnNull(); $clients = m::mock(ClientRepository::class); $clients->shouldReceive('find')->with(1)->andReturn($client = m::mock(Client::class)); @@ -228,15 +239,11 @@ public function test_authorization_view_is_presented_if_request_has_prompt_equal $controller = new AuthorizationController($server, $guard, $response, $clients); - $this->assertSame($response, $controller->authorize( - m::mock(ServerRequestInterface::class), $request - )); + $this->assertSame($response, $controller->authorize($psrRequest, $request)); } public function test_authorization_denied_if_request_has_prompt_equals_to_none() { - $this->expectException('Laravel\Passport\Exceptions\OAuthServerException'); - Passport::tokensCan([ 'scope-1' => 'description', ]); @@ -249,12 +256,9 @@ public function test_authorization_denied_if_request_has_prompt_equals_to_none() $guard->shouldReceive('user')->andReturn($user = m::mock(Authenticatable::class)); $server->shouldReceive('validateAuthorizationRequest') ->andReturn($authRequest = m::mock(AuthorizationRequest::class)); - $server->shouldReceive('completeAuthorizationRequest') - ->with($authRequest, m::type(ResponseInterface::class)) - ->once() - ->andReturnUsing(function () { - throw new \League\OAuth2\Server\Exception\OAuthServerException('', 0, ''); - }); + + $psrRequest = m::mock(ServerRequestInterface::class); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); $request = m::mock(Request::class); $request->shouldReceive('session')->andReturn($session = m::mock()); @@ -265,7 +269,9 @@ public function test_authorization_denied_if_request_has_prompt_equals_to_none() $authRequest->shouldReceive('getClient->getIdentifier')->once()->andReturn(1); $authRequest->shouldReceive('getScopes')->once()->andReturn([new Scope('scope-1')]); $authRequest->shouldReceive('setUser')->once()->andReturnNull(); - $authRequest->shouldReceive('setAuthorizationApproved')->once()->with(false); + $authRequest->shouldReceive('getRedirectUri')->once()->andReturn('http://localhost'); + $authRequest->shouldReceive('getState')->once()->andReturn('state'); + $authRequest->shouldReceive('getGrantTypeId')->once()->andReturn('authorization_code'); $clients = m::mock(ClientRepository::class); $clients->shouldReceive('find')->with(1)->andReturn($client = m::mock(Client::class)); @@ -275,9 +281,19 @@ public function test_authorization_denied_if_request_has_prompt_equals_to_none() $controller = new AuthorizationController($server, $guard, $response, $clients); - $controller->authorize( - m::mock(ServerRequestInterface::class), $request - ); + try { + $controller->authorize($psrRequest, $request); + } catch (OAuthServerException $e) { + $this->assertSame($e->getMessage(), 'The authorization server requires end-user consent.'); + $this->assertStringStartsWith( + 'http://localhost?state=state&error=consent_required&error_description=', + $e->getResponse()->headers->get('location') + ); + + return; + } + + $this->expectException(OAuthServerException::class); } public function test_authorization_denied_if_unauthenticated_and_request_has_prompt_equals_to_none() @@ -291,6 +307,9 @@ public function test_authorization_denied_if_unauthenticated_and_request_has_pro ->andReturn($authRequest = m::mock(AuthorizationRequest::class)); $server->shouldNotReceive('completeAuthorizationRequest'); + $psrRequest = m::mock(ServerRequestInterface::class); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $request = m::mock(Request::class); $request->shouldNotReceive('user'); $request->shouldReceive('get')->with('prompt')->andReturn('none'); @@ -299,23 +318,26 @@ public function test_authorization_denied_if_unauthenticated_and_request_has_pro $authRequest->shouldReceive('setAuthorizationApproved')->with(false); $authRequest->shouldReceive('getRedirectUri')->andReturn('http://localhost'); $authRequest->shouldReceive('getClient->getRedirectUri')->andReturn('http://localhost'); - $authRequest->shouldReceive('getState')->andReturn('state'); - $authRequest->shouldReceive('getGrantTypeId')->andReturn('authorization_code'); + $authRequest->shouldReceive('getState')->once()->andReturn('state'); + $authRequest->shouldReceive('getGrantTypeId')->once()->andReturn('authorization_code'); $clients = m::mock(ClientRepository::class); $controller = new AuthorizationController($server, $guard, $response, $clients); try { - $controller->authorize( - m::mock(ServerRequestInterface::class), $request - ); - } catch (\Laravel\Passport\Exceptions\OAuthServerException $e) { + $controller->authorize($psrRequest, $request); + } catch (OAuthServerException $e) { + $this->assertSame($e->getMessage(), 'The authorization server requires end-user authentication.'); $this->assertStringStartsWith( - 'http://localhost?state=state&error=access_denied&error_description=', + 'http://localhost?state=state&error=login_required&error_description=', $e->getResponse()->headers->get('location') ); + + return; } + + $this->expectException(OAuthServerException::class); } public function test_logout_and_prompt_login_if_request_has_prompt_equals_to_login() @@ -330,6 +352,9 @@ public function test_logout_and_prompt_login_if_request_has_prompt_equals_to_log $server->shouldReceive('validateAuthorizationRequest')->once(); $guard->shouldReceive('logout')->once(); + $psrRequest = m::mock(ServerRequestInterface::class); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $request = m::mock(Request::class); $request->shouldReceive('session')->andReturn($session = m::mock()); $session->shouldReceive('invalidate')->once(); @@ -343,9 +368,7 @@ public function test_logout_and_prompt_login_if_request_has_prompt_equals_to_log $controller = new AuthorizationController($server, $guard, $response, $clients); - $controller->authorize( - m::mock(ServerRequestInterface::class), $request - ); + $controller->authorize($psrRequest, $request); } public function test_user_should_be_authenticated() @@ -359,6 +382,9 @@ public function test_user_should_be_authenticated() $guard->shouldReceive('guest')->andReturn(true); $server->shouldReceive('validateAuthorizationRequest')->once(); + $psrRequest = m::mock(ServerRequestInterface::class); + $psrRequest->shouldReceive('getQueryParams')->andReturn([]); + $request = m::mock(Request::class); $request->shouldNotReceive('user'); $request->shouldReceive('session')->andReturn($session = m::mock()); @@ -370,8 +396,6 @@ public function test_user_should_be_authenticated() $controller = new AuthorizationController($server, $guard, $response, $clients); - $controller->authorize( - m::mock(ServerRequestInterface::class), $request, $clients - ); + $controller->authorize($psrRequest, $request); } } diff --git a/tests/Unit/DenyAuthorizationControllerTest.php b/tests/Unit/DenyAuthorizationControllerTest.php index 843e31b0..e5d7505e 100644 --- a/tests/Unit/DenyAuthorizationControllerTest.php +++ b/tests/Unit/DenyAuthorizationControllerTest.php @@ -25,7 +25,6 @@ public function test_authorization_can_be_denied() $request = m::mock(Request::class); $request->shouldReceive('session')->andReturn($session = m::mock()); - $request->shouldReceive('user')->andReturn(new DenyAuthorizationControllerFakeUser); $request->shouldReceive('isNotFilled')->with('auth_token')->andReturn(false); $request->shouldReceive('get')->with('auth_token')->andReturn('foo'); @@ -37,7 +36,7 @@ public function test_authorization_can_be_denied() AuthorizationRequest::class )); - $authRequest->shouldReceive('setUser')->once(); + $authRequest->shouldReceive('getGrantTypeId')->once()->andReturn('authorization_code'); $authRequest->shouldReceive('setAuthorizationApproved')->once()->with(false); $server->shouldReceive('completeAuthorizationRequest')