IndieAuth endpoint can now return access tokens
This commit is contained in:
parent
5b2bfd5270
commit
7f70f75d05
6 changed files with 683 additions and 244 deletions
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\TokenService;
|
||||
use GuzzleHttp\Client as GuzzleClient;
|
||||
use GuzzleHttp\Exception\BadResponseException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use IndieAuth\Client;
|
||||
use JsonException;
|
||||
|
||||
/**
|
||||
* @psalm-suppress UnusedClass
|
||||
*/
|
||||
class TokenEndpointController extends Controller
|
||||
{
|
||||
/**
|
||||
* @var Client The IndieAuth Client.
|
||||
*/
|
||||
protected Client $client;
|
||||
|
||||
/**
|
||||
* @var GuzzleClient The GuzzleHttp client.
|
||||
*/
|
||||
protected GuzzleClient $guzzle;
|
||||
|
||||
protected TokenService $tokenService;
|
||||
|
||||
/**
|
||||
* Inject the dependencies.
|
||||
*/
|
||||
public function __construct(
|
||||
Client $client,
|
||||
GuzzleClient $guzzle,
|
||||
TokenService $tokenService
|
||||
) {
|
||||
$this->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;
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Example App</title>
|
||||
<link rel="redirect_uri" href="example-app://callback">
|
||||
</head>
|
||||
<body>
|
||||
<div class="h-app">
|
||||
<a href="/" class="u-url p-name">Example App</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
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'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Example App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="h-app">
|
||||
<a href="/" class="u-url p-name">Example App</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Exception;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use JsonException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TokenEndpointTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
*
|
||||
* @throws JsonException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function tokenEndpointIssuesToken(): void
|
||||
{
|
||||
$mockHandler = new MockHandler([
|
||||
new \GuzzleHttp\Psr7\Response(200, [], json_encode([
|
||||
'me' => 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',
|
||||
]);
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue