Merge pull request #1414 from jonnybarnes/1384-implement-auth-endpoint
Implement IndieAuth endpoint
This commit is contained in:
commit
a440533a76
25 changed files with 1950 additions and 686 deletions
|
@ -19,7 +19,6 @@ use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use ParagonIE\ConstantTime\Base64UrlSafe;
|
use ParagonIE\ConstantTime\Base64UrlSafe;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
use Webauthn\AttestationStatement\AttestationObjectLoader;
|
|
||||||
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
|
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
|
||||||
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
|
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
|
||||||
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
|
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
|
||||||
|
@ -28,9 +27,11 @@ use Webauthn\AuthenticatorAssertionResponseValidator;
|
||||||
use Webauthn\AuthenticatorAttestationResponse;
|
use Webauthn\AuthenticatorAttestationResponse;
|
||||||
use Webauthn\AuthenticatorAttestationResponseValidator;
|
use Webauthn\AuthenticatorAttestationResponseValidator;
|
||||||
use Webauthn\AuthenticatorSelectionCriteria;
|
use Webauthn\AuthenticatorSelectionCriteria;
|
||||||
|
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
|
||||||
|
use Webauthn\Denormalizer\WebauthnSerializerFactory;
|
||||||
use Webauthn\Exception\WebauthnException;
|
use Webauthn\Exception\WebauthnException;
|
||||||
|
use Webauthn\PublicKeyCredential;
|
||||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||||
use Webauthn\PublicKeyCredentialLoader;
|
|
||||||
use Webauthn\PublicKeyCredentialParameters;
|
use Webauthn\PublicKeyCredentialParameters;
|
||||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||||
use Webauthn\PublicKeyCredentialRpEntity;
|
use Webauthn\PublicKeyCredentialRpEntity;
|
||||||
|
@ -109,39 +110,57 @@ class PasskeysController extends Controller
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
$publicKeyCredentialCreationOptionsData = session('create_options');
|
$publicKeyCredentialCreationOptionsData = session('create_options');
|
||||||
|
// Unset session data to mitigate replay attacks
|
||||||
|
session()->forget('create_options');
|
||||||
if (empty($publicKeyCredentialCreationOptionsData)) {
|
if (empty($publicKeyCredentialCreationOptionsData)) {
|
||||||
throw new WebAuthnException('No public key credential request options found');
|
throw new WebAuthnException('No public key credential request options found');
|
||||||
}
|
}
|
||||||
$publicKeyCredentialCreationOptions = PublicKeyCredentialCreationOptions::createFromString($publicKeyCredentialCreationOptionsData);
|
|
||||||
|
|
||||||
// Unset session data to mitigate replay attacks
|
$attestationStatementSupportManager = new AttestationStatementSupportManager();
|
||||||
session()->forget('create_options');
|
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport());
|
||||||
|
|
||||||
$attestationSupportManager = AttestationStatementSupportManager::create();
|
$webauthnSerializer = (new WebauthnSerializerFactory(
|
||||||
$attestationSupportManager->add(NoneAttestationStatementSupport::create());
|
$attestationStatementSupportManager
|
||||||
$attestationObjectLoader = AttestationObjectLoader::create($attestationSupportManager);
|
))->create();
|
||||||
$publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader);
|
|
||||||
|
|
||||||
$publicKeyCredential = $publicKeyCredentialLoader->load(json_encode($request->all(), JSON_THROW_ON_ERROR));
|
$publicKeyCredential = $webauthnSerializer->deserialize(
|
||||||
|
json_encode($request->all(), JSON_THROW_ON_ERROR),
|
||||||
|
PublicKeyCredential::class,
|
||||||
|
'json'
|
||||||
|
);
|
||||||
|
|
||||||
if (! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) {
|
if (! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) {
|
||||||
throw new WebAuthnException('Invalid response type');
|
throw new WebAuthnException('Invalid response type');
|
||||||
}
|
}
|
||||||
|
|
||||||
$attestationStatementSupportManager = AttestationStatementSupportManager::create();
|
$algorithmManager = new Manager();
|
||||||
$attestationStatementSupportManager->add(NoneAttestationStatementSupport::create());
|
$algorithmManager->add(new Ed25519());
|
||||||
|
$algorithmManager->add(new ES256());
|
||||||
|
$algorithmManager->add(new RS256());
|
||||||
|
|
||||||
$authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create(
|
$ceremonyStepManagerFactory = new CeremonyStepManagerFactory();
|
||||||
attestationStatementSupportManager: $attestationStatementSupportManager,
|
$ceremonyStepManagerFactory->setAlgorithmManager($algorithmManager);
|
||||||
publicKeyCredentialSourceRepository: null,
|
$ceremonyStepManagerFactory->setAttestationStatementSupportManager(
|
||||||
tokenBindingHandler: null,
|
$attestationStatementSupportManager
|
||||||
extensionOutputCheckerHandler: ExtensionOutputCheckerHandler::create(),
|
);
|
||||||
|
$ceremonyStepManagerFactory->setExtensionOutputCheckerHandler(
|
||||||
|
ExtensionOutputCheckerHandler::create()
|
||||||
);
|
);
|
||||||
|
|
||||||
$securedRelyingPartyId = [];
|
$securedRelyingPartyId = [];
|
||||||
if (App::environment('local', 'development')) {
|
if (App::environment('local', 'development')) {
|
||||||
$securedRelyingPartyId = [config('url.longurl')];
|
$securedRelyingPartyId = [config('url.longurl')];
|
||||||
}
|
}
|
||||||
|
$ceremonyStepManagerFactory->setSecuredRelyingPartyId($securedRelyingPartyId);
|
||||||
|
|
||||||
|
$authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create(
|
||||||
|
ceremonyStepManager: $ceremonyStepManagerFactory->creationCeremony()
|
||||||
|
);
|
||||||
|
|
||||||
|
$publicKeyCredentialCreationOptions = $webauthnSerializer->deserialize(
|
||||||
|
$publicKeyCredentialCreationOptionsData,
|
||||||
|
PublicKeyCredentialCreationOptions::class,
|
||||||
|
'json'
|
||||||
|
);
|
||||||
|
|
||||||
$publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check(
|
$publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check(
|
||||||
authenticatorAttestationResponse: $publicKeyCredential->response,
|
authenticatorAttestationResponse: $publicKeyCredential->response,
|
||||||
|
@ -187,14 +206,18 @@ class PasskeysController extends Controller
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$publicKeyCredentialRequestOptions = PublicKeyCredentialRequestOptions::createFromString($requestOptions);
|
$attestationStatementSupportManager = new AttestationStatementSupportManager();
|
||||||
|
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport());
|
||||||
|
|
||||||
$attestationSupportManager = AttestationStatementSupportManager::create();
|
$webauthnSerializer = (new WebauthnSerializerFactory(
|
||||||
$attestationSupportManager->add(NoneAttestationStatementSupport::create());
|
$attestationStatementSupportManager
|
||||||
$attestationObjectLoader = AttestationObjectLoader::create($attestationSupportManager);
|
))->create();
|
||||||
$publicKeyCredentialLoader = PublicKeyCredentialLoader::create($attestationObjectLoader);
|
|
||||||
|
|
||||||
$publicKeyCredential = $publicKeyCredentialLoader->load(json_encode($request->all(), JSON_THROW_ON_ERROR));
|
$publicKeyCredential = $webauthnSerializer->deserialize(
|
||||||
|
json_encode($request->all(), JSON_THROW_ON_ERROR),
|
||||||
|
PublicKeyCredential::class,
|
||||||
|
'json'
|
||||||
|
);
|
||||||
|
|
||||||
if (! $publicKeyCredential->response instanceof AuthenticatorAssertionResponse) {
|
if (! $publicKeyCredential->response instanceof AuthenticatorAssertionResponse) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
@ -211,28 +234,47 @@ class PasskeysController extends Controller
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$credential = PublicKeyCredentialSource::createFromArray(json_decode($passkey->passkey, true, 512, JSON_THROW_ON_ERROR));
|
$publicKeyCredentialSource = $webauthnSerializer->deserialize(
|
||||||
|
$passkey->passkey,
|
||||||
|
PublicKeyCredentialSource::class,
|
||||||
|
'json'
|
||||||
|
);
|
||||||
|
|
||||||
$algorithmManager = Manager::create();
|
$algorithmManager = new Manager();
|
||||||
$algorithmManager->add(new Ed25519());
|
$algorithmManager->add(new Ed25519());
|
||||||
$algorithmManager->add(new ES256());
|
$algorithmManager->add(new ES256());
|
||||||
$algorithmManager->add(new RS256());
|
$algorithmManager->add(new RS256());
|
||||||
|
|
||||||
$authenticatorAssertionResponseValidator = new AuthenticatorAssertionResponseValidator(
|
$attestationStatementSupportManager = new AttestationStatementSupportManager();
|
||||||
publicKeyCredentialSourceRepository: null,
|
$attestationStatementSupportManager->add(new NoneAttestationStatementSupport());
|
||||||
tokenBindingHandler: null,
|
|
||||||
extensionOutputCheckerHandler: ExtensionOutputCheckerHandler::create(),
|
|
||||||
algorithmManager: $algorithmManager,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
$ceremonyStepManagerFactory = new CeremonyStepManagerFactory();
|
||||||
|
$ceremonyStepManagerFactory->setAlgorithmManager($algorithmManager);
|
||||||
|
$ceremonyStepManagerFactory->setAttestationStatementSupportManager(
|
||||||
|
$attestationStatementSupportManager
|
||||||
|
);
|
||||||
|
$ceremonyStepManagerFactory->setExtensionOutputCheckerHandler(
|
||||||
|
ExtensionOutputCheckerHandler::create()
|
||||||
|
);
|
||||||
$securedRelyingPartyId = [];
|
$securedRelyingPartyId = [];
|
||||||
if (App::environment('local', 'development')) {
|
if (App::environment('local', 'development')) {
|
||||||
$securedRelyingPartyId = [config('url.longurl')];
|
$securedRelyingPartyId = [config('url.longurl')];
|
||||||
}
|
}
|
||||||
|
$ceremonyStepManagerFactory->setSecuredRelyingPartyId($securedRelyingPartyId);
|
||||||
|
|
||||||
|
$authenticatorAssertionResponseValidator = AuthenticatorAssertionResponseValidator::create(
|
||||||
|
ceremonyStepManager: $ceremonyStepManagerFactory->requestCeremony()
|
||||||
|
);
|
||||||
|
|
||||||
|
$publicKeyCredentialRequestOptions = $webauthnSerializer->deserialize(
|
||||||
|
$requestOptions,
|
||||||
|
PublicKeyCredentialRequestOptions::class,
|
||||||
|
'json'
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$authenticatorAssertionResponseValidator->check(
|
$authenticatorAssertionResponseValidator->check(
|
||||||
credentialId: $credential,
|
credentialId: $publicKeyCredentialSource,
|
||||||
authenticatorAssertionResponse: $publicKeyCredential->response,
|
authenticatorAssertionResponse: $publicKeyCredential->response,
|
||||||
publicKeyCredentialRequestOptions: $publicKeyCredentialRequestOptions,
|
publicKeyCredentialRequestOptions: $publicKeyCredentialRequestOptions,
|
||||||
request: config('url.longurl'),
|
request: config('url.longurl'),
|
||||||
|
|
337
app/Http/Controllers/IndieAuthController.php
Normal file
337
app/Http/Controllers/IndieAuthController.php
Normal file
|
@ -0,0 +1,337 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Random\RandomException;
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* This is the first step in the IndieAuth flow, where the client app sends the user to the IndieAuth endpoint.
|
||||||
|
*/
|
||||||
|
public function start(Request $request): View
|
||||||
|
{
|
||||||
|
// First check all required params are present
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'response_type' => 'required:string',
|
||||||
|
'client_id' => 'required',
|
||||||
|
'redirect_uri' => 'required',
|
||||||
|
'state' => 'required',
|
||||||
|
'code_challenge' => 'required:string',
|
||||||
|
'code_challenge_method' => 'required:string',
|
||||||
|
], [
|
||||||
|
'response_type' => 'response_type is required',
|
||||||
|
'client_id.required' => 'client_id is required to display which app is asking for authentication',
|
||||||
|
'redirect_uri.required' => 'redirect_uri is required so we can progress successful requests',
|
||||||
|
'state.required' => 'state is required',
|
||||||
|
'code_challenge.required' => 'code_challenge is required',
|
||||||
|
'code_challenge_method.required' => 'code_challenge_method is required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return view('indieauth.error')->withErrors($validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->get('response_type') !== 'code') {
|
||||||
|
return view('indieauth.error')->withErrors(['response_type' => 'only a response_type of "code" is supported']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strtoupper($request->get('code_challenge_method')) !== 'S256') {
|
||||||
|
return view('indieauth.error')->withErrors(['code_challenge_method' => 'only a code_challenge_method of "S256" is supported']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->isValidRedirectUri($request->get('client_id'), $request->get('redirect_uri'))) {
|
||||||
|
return view('indieauth.error')->withErrors(['redirect_uri' => 'redirect_uri is not valid for this client_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopes = $request->get('scopes', '');
|
||||||
|
$scopes = explode(' ', $scopes);
|
||||||
|
|
||||||
|
return view('indieauth.start', [
|
||||||
|
'me' => $request->get('me'),
|
||||||
|
'client_id' => $request->get('client_id'),
|
||||||
|
'redirect_uri' => $request->get('redirect_uri'),
|
||||||
|
'state' => $request->get('state'),
|
||||||
|
'scopes' => $scopes,
|
||||||
|
'code_challenge' => $request->get('code_challenge'),
|
||||||
|
'code_challenge_method' => $request->get('code_challenge_method'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm an IndieAuth approval request.
|
||||||
|
*
|
||||||
|
* Generates an auth code and redirects the user back to the client app.
|
||||||
|
*
|
||||||
|
* @throws RandomException
|
||||||
|
*/
|
||||||
|
public function confirm(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$authCode = bin2hex(random_bytes(16));
|
||||||
|
|
||||||
|
$cacheKey = hash('xxh3', $request->get('client_id'));
|
||||||
|
|
||||||
|
$indieAuthRequestData = [
|
||||||
|
'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));
|
||||||
|
|
||||||
|
$redirectUri = new Uri($request->get('redirect_uri'));
|
||||||
|
$redirectUri = Uri::withQueryValues($redirectUri, [
|
||||||
|
'code' => $authCode,
|
||||||
|
'state' => $request->get('state'),
|
||||||
|
'iss' => config('app.url'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// For now just dump URL scheme
|
||||||
|
// return response()->json([
|
||||||
|
// 'redirect_uri' => $redirectUri,
|
||||||
|
// ]);
|
||||||
|
|
||||||
|
return redirect()->away($redirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @throws SodiumException
|
||||||
|
*/
|
||||||
|
public function processCodeExchange(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$invalidCodeResponse = $this->validateAuthorizationCode($request);
|
||||||
|
|
||||||
|
if ($invalidCodeResponse instanceof JsonResponse) {
|
||||||
|
return $invalidCodeResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'me' => config('app.url'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
$clientIdParsed = \Mf2\parseUriToComponents($clientId);
|
||||||
|
if (! isset($clientIdParsed['authority'])) {
|
||||||
|
ray($clientIdParsed);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If redirect_uri is not a valid URL, then it's not valid
|
||||||
|
$redirectUriParsed = \Mf2\parseUriToComponents($redirectUri);
|
||||||
|
if (! isset($redirectUriParsed['authority'])) {
|
||||||
|
ray($redirectUriParsed);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If client_id and redirect_uri are the same host, then it's valid
|
||||||
|
if ($clientIdParsed['authority'] === $redirectUriParsed['authority']) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise we need to check the redirect_uri is in the client_id's redirect_uris
|
||||||
|
$guzzle = resolve(Client::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$clientInfo = $guzzle->get($clientId);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
ray('Failed to fetch client info', $e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId);
|
||||||
|
|
||||||
|
$redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? [];
|
||||||
|
|
||||||
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -16,10 +16,11 @@ class LinkHeadersMiddleware
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
$response = $next($request);
|
$response = $next($request);
|
||||||
$response->header('Link', '<https://indieauth.com/auth>; rel="authorization_endpoint"', false);
|
$response->header('Link', '<' . route('indieauth.metadata') . '>; rel="indieauth-metadata"', false);
|
||||||
$response->header('Link', '<' . config('app.url') . '/api/token>; rel="token_endpoint"', false);
|
$response->header('Link', '<' . route('indieauth.start') . '>; rel="authorization_endpoint"', false);
|
||||||
$response->header('Link', '<' . config('app.url') . '/api/post>; rel="micropub"', false);
|
$response->header('Link', '<' . route('indieauth.token') . '>; rel="token_endpoint"', false);
|
||||||
$response->header('Link', '<' . config('app.url') . '/webmention>; rel="webmention"', false);
|
$response->header('Link', '<' . route('micropub-endpoint') . '>; rel="micropub"', false);
|
||||||
|
$response->header('Link', '<' . route('webmention-endpoint') . '>; rel="webmention"', false);
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,8 @@ class MyAuthMiddleware
|
||||||
{
|
{
|
||||||
if (Auth::check() === false) {
|
if (Auth::check() === false) {
|
||||||
// they’re not logged in, so send them to login form
|
// they’re not logged in, so send them to login form
|
||||||
|
redirect()->setIntendedUrl($request->url());
|
||||||
|
|
||||||
return redirect()->route('login');
|
return redirect()->route('login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\LinkHeadersMiddleware;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
@ -11,8 +12,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
$middleware->validateCsrfTokens(except: [
|
$middleware
|
||||||
'api/token',
|
->append(LinkHeadersMiddleware::class)
|
||||||
|
->validateCsrfTokens(except: [
|
||||||
|
'auth', // This is the IndieAuth auth endpoint
|
||||||
|
'token', // This is the IndieAuth token endpoint
|
||||||
'api/post',
|
'api/post',
|
||||||
'api/media',
|
'api/media',
|
||||||
'micropub/places',
|
'micropub/places',
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
"ext-intl": "*",
|
"ext-intl": "*",
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"ext-pgsql": "*",
|
"ext-pgsql": "*",
|
||||||
|
"ext-sodium": "*",
|
||||||
"cviebrock/eloquent-sluggable": "^11.0",
|
"cviebrock/eloquent-sluggable": "^11.0",
|
||||||
"guzzlehttp/guzzle": "^7.2",
|
"guzzlehttp/guzzle": "^7.2",
|
||||||
"indieauth/client": "^1.1",
|
"indieauth/client": "^1.1",
|
||||||
|
@ -26,9 +27,12 @@
|
||||||
"league/commonmark": "^2.0",
|
"league/commonmark": "^2.0",
|
||||||
"league/flysystem-aws-s3-v3": "^3.0",
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
"mf2/mf2": "~0.3",
|
"mf2/mf2": "~0.3",
|
||||||
|
"phpdocumentor/reflection-docblock": "^5.3",
|
||||||
"spatie/commonmark-highlighter": "^3.0",
|
"spatie/commonmark-highlighter": "^3.0",
|
||||||
"spatie/laravel-ignition": "^2.1",
|
"spatie/laravel-ignition": "^2.1",
|
||||||
"symfony/html-sanitizer": "^7.0",
|
"symfony/html-sanitizer": "^7.0",
|
||||||
|
"symfony/property-access": "^7.0",
|
||||||
|
"symfony/serializer": "^7.0",
|
||||||
"web-auth/webauthn-lib": "^4.7"
|
"web-auth/webauthn-lib": "^4.7"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|
1146
composer.lock
generated
1146
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -29,15 +29,4 @@ return [
|
||||||
|
|
||||||
'shorturl' => env('APP_SHORTURL', 'shorturl.local'),
|
'shorturl' => env('APP_SHORTURL', 'shorturl.local'),
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Authorization endpoint
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| The authorization endpoint for the application, used primarily for Micropub
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'authorization_endpoint' => env('AUTHORIZATION_ENDPOINT', 'https://indieauth.com/auth'),
|
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
# For more information: https://laravel.com/docs/sail
|
# For more information: https://laravel.com/docs/sail
|
||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
laravel.test:
|
laravel.test:
|
||||||
build:
|
build:
|
||||||
|
@ -18,6 +17,7 @@ services:
|
||||||
LARAVEL_SAIL: 1
|
LARAVEL_SAIL: 1
|
||||||
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
||||||
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
||||||
|
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
||||||
volumes:
|
volumes:
|
||||||
- '.:/var/www/html'
|
- '.:/var/www/html'
|
||||||
networks:
|
networks:
|
||||||
|
|
|
@ -5,3 +5,4 @@
|
||||||
@import url('code.css');
|
@import url('code.css');
|
||||||
@import url('content.css');
|
@import url('content.css');
|
||||||
@import url('notes.css');
|
@import url('notes.css');
|
||||||
|
@import url('indieauth.css');
|
||||||
|
|
Binary file not shown.
15
public/assets/css/indieauth.css
Normal file
15
public/assets/css/indieauth.css
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
.indieauth {
|
||||||
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
background-color: var(--color-danger-shadow);
|
||||||
|
border: 1px solid var(--color-danger);
|
||||||
|
border-radius: .5rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
padding-block: .5rem;
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
margin-block-end: 1rem;
|
||||||
|
}
|
||||||
|
}
|
BIN
public/assets/css/indieauth.css.br
Normal file
BIN
public/assets/css/indieauth.css.br
Normal file
Binary file not shown.
|
@ -20,4 +20,6 @@
|
||||||
--color-link-visited: oklch(70.44% 0.21 304.41deg);
|
--color-link-visited: oklch(70.44% 0.21 304.41deg);
|
||||||
--color-primary-shadow: oklch(19.56% 0.054 125.505deg / 40%);
|
--color-primary-shadow: oklch(19.56% 0.054 125.505deg / 40%);
|
||||||
--rss-color-link: oklch(67.59% 0.189 42.04deg);
|
--rss-color-link: oklch(67.59% 0.189 42.04deg);
|
||||||
|
--color-danger: oklch(64.41% 0.281 23.29deg);
|
||||||
|
--color-danger-shadow: oklch(64.41% 0.281 23.29deg / 10%);
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
12
resources/views/indieauth/error.blade.php
Normal file
12
resources/views/indieauth/error.blade.php
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
@extends('master')
|
||||||
|
|
||||||
|
@section('title')IndieAuth « @stop
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<section class="indieauth">
|
||||||
|
<h1>IndieAuth</h1>
|
||||||
|
@foreach ($errors->all() as $message)
|
||||||
|
<div class="error">{{ $message }}</div>
|
||||||
|
@endforeach
|
||||||
|
</section>
|
||||||
|
@stop
|
39
resources/views/indieauth/start.blade.php
Normal file
39
resources/views/indieauth/start.blade.php
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
@extends('master')
|
||||||
|
|
||||||
|
@section('title')IndieAuth « @stop
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<form class="indieauth" action="/auth/confirm" method="post">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<input type="hidden" name="client_id" value="{{ $client_id }}">
|
||||||
|
<input type="hidden" name="redirect_uri" value="{{ $redirect_uri }}">
|
||||||
|
<input type="hidden" name="state" value="{{ $state }}">
|
||||||
|
<input type="hidden" name="me" value="{{ $me }}">
|
||||||
|
<input type="hidden" name="code_challenge" value="{{ $code_challenge }}">
|
||||||
|
<input type="hidden" name="code_challenge_method" value="{{ $code_challenge_method }}">
|
||||||
|
@if(!empty($scopes))
|
||||||
|
@foreach($scopes as $scope)
|
||||||
|
<input type="hidden" name="scope[]" value="{{ $scope }}">
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<h1>IndieAuth</h1>
|
||||||
|
@if(!empty($error))
|
||||||
|
<div class="error">{{ $error }}</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<p>You are attempting to log in with the client <code>{{ $client_id }}</code></p>
|
||||||
|
<p>After approving the request you will be redirected to <code>{{ $redirect_uri }}</code></p>
|
||||||
|
@if(!empty($scopes))
|
||||||
|
<p>The client is requesting the following scopes:</p>
|
||||||
|
<ul>
|
||||||
|
@foreach($scopes as $scope)
|
||||||
|
<li>{{ $scope }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<button type="submit">Approve</button>
|
||||||
|
</form>
|
||||||
|
@stop
|
|
@ -16,8 +16,9 @@
|
||||||
<link rel="alternate" type="application/jf2feed+json" title="Notes JF2 Feed" href="/blog/feed.jf2">
|
<link rel="alternate" type="application/jf2feed+json" title="Notes JF2 Feed" href="/blog/feed.jf2">
|
||||||
<link rel="openid.server" href="https://indieauth.com/openid">
|
<link rel="openid.server" href="https://indieauth.com/openid">
|
||||||
<link rel="openid.delegate" href="{{ config('app.url') }}">
|
<link rel="openid.delegate" href="{{ config('app.url') }}">
|
||||||
<link rel="authorization_endpoint" href="{{ config('url.authorization_endpoint') }}">
|
<link rel="indieauth-metadata" href="{{ config('app.url') }}/.well-known/indieauth-server">
|
||||||
<link rel="token_endpoint" href="{{ config('app.url') }}/api/token">
|
<link rel="authorization_endpoint" href="{{ config('app.url') }}/auth }}">
|
||||||
|
<link rel="token_endpoint" href="{{ config('app.url') }}/token">
|
||||||
<link rel="micropub" href="{{ config('app.url') }}/api/post">
|
<link rel="micropub" href="{{ config('app.url') }}/api/post">
|
||||||
<link rel="webmention" href="{{ config('app.url') }}/webmention">
|
<link rel="webmention" href="{{ config('app.url') }}/webmention">
|
||||||
<link rel="shortcut icon" href="{{ config('app.url') }}/assets/img/memoji-orange-bg-small-fs8.png">
|
<link rel="shortcut icon" href="{{ config('app.url') }}/assets/img/memoji-orange-bg-small-fs8.png">
|
||||||
|
|
|
@ -16,6 +16,7 @@ use App\Http\Controllers\BookmarksController;
|
||||||
use App\Http\Controllers\ContactsController;
|
use App\Http\Controllers\ContactsController;
|
||||||
use App\Http\Controllers\FeedsController;
|
use App\Http\Controllers\FeedsController;
|
||||||
use App\Http\Controllers\FrontPageController;
|
use App\Http\Controllers\FrontPageController;
|
||||||
|
use App\Http\Controllers\IndieAuthController;
|
||||||
use App\Http\Controllers\LikesController;
|
use App\Http\Controllers\LikesController;
|
||||||
use App\Http\Controllers\MicropubController;
|
use App\Http\Controllers\MicropubController;
|
||||||
use App\Http\Controllers\MicropubMediaController;
|
use App\Http\Controllers\MicropubMediaController;
|
||||||
|
@ -23,7 +24,6 @@ use App\Http\Controllers\NotesController;
|
||||||
use App\Http\Controllers\PlacesController;
|
use App\Http\Controllers\PlacesController;
|
||||||
use App\Http\Controllers\SearchController;
|
use App\Http\Controllers\SearchController;
|
||||||
use App\Http\Controllers\ShortURLsController;
|
use App\Http\Controllers\ShortURLsController;
|
||||||
use App\Http\Controllers\TokenEndpointController;
|
|
||||||
use App\Http\Controllers\WebMentionsController;
|
use App\Http\Controllers\WebMentionsController;
|
||||||
use App\Http\Middleware\CorsHeaders;
|
use App\Http\Middleware\CorsHeaders;
|
||||||
use App\Http\Middleware\MyAuthMiddleware;
|
use App\Http\Middleware\MyAuthMiddleware;
|
||||||
|
@ -190,12 +190,16 @@ Route::domain(config('url.longurl'))->group(function () {
|
||||||
Route::get('/tagged/{tag}', [BookmarksController::class, 'tagged']);
|
Route::get('/tagged/{tag}', [BookmarksController::class, 'tagged']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Token Endpoint
|
// IndieAuth
|
||||||
Route::post('api/token', [TokenEndpointController::class, 'create']);
|
Route::get('.well-known/indieauth-server', [IndieAuthController::class, 'indieAuthMetadataEndpoint'])->name('indieauth.metadata');
|
||||||
|
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::post('token', [IndieAuthController::class, 'processTokenRequest'])->name('indieauth.token');
|
||||||
|
|
||||||
// Micropub Endpoints
|
// Micropub Endpoints
|
||||||
Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class);
|
Route::get('api/post', [MicropubController::class, 'get'])->middleware(VerifyMicropubToken::class);
|
||||||
Route::post('api/post', [MicropubController::class, 'post'])->middleware(VerifyMicropubToken::class);
|
Route::post('api/post', [MicropubController::class, 'post'])->middleware(VerifyMicropubToken::class)->name('micropub-endpoint');
|
||||||
Route::get('api/media', [MicropubMediaController::class, 'getHandler'])->middleware(VerifyMicropubToken::class);
|
Route::get('api/media', [MicropubMediaController::class, 'getHandler'])->middleware(VerifyMicropubToken::class);
|
||||||
Route::post('api/media', [MicropubMediaController::class, 'media'])
|
Route::post('api/media', [MicropubMediaController::class, 'media'])
|
||||||
->middleware([VerifyMicropubToken::class, CorsHeaders::class])
|
->middleware([VerifyMicropubToken::class, CorsHeaders::class])
|
||||||
|
@ -203,7 +207,7 @@ Route::domain(config('url.longurl'))->group(function () {
|
||||||
Route::options('/api/media', [MicropubMediaController::class, 'mediaOptionsResponse'])->middleware(CorsHeaders::class);
|
Route::options('/api/media', [MicropubMediaController::class, 'mediaOptionsResponse'])->middleware(CorsHeaders::class);
|
||||||
|
|
||||||
// Webmention
|
// Webmention
|
||||||
Route::get('webmention', [WebMentionsController::class, 'get']);
|
Route::get('webmention', [WebMentionsController::class, 'get'])->name('webmention-endpoint');
|
||||||
Route::post('webmention', [WebMentionsController::class, 'receive']);
|
Route::post('webmention', [WebMentionsController::class, 'receive']);
|
||||||
|
|
||||||
// Contacts
|
// Contacts
|
||||||
|
|
25
tests/Feature/HeaderLinkTest.php
Normal file
25
tests/Feature/HeaderLinkTest.php
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class HeaderLinkTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function itShouldSeeTheIndiewebRelatedLinkHeaders(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/');
|
||||||
|
|
||||||
|
$linkHeaders = $response->headers->allPreserveCaseWithoutCookies()['Link'];
|
||||||
|
|
||||||
|
$this->assertSame('<' . config('app.url') . '/.well-known/indieauth-server>; rel="indieauth-metadata"', $linkHeaders[0]);
|
||||||
|
$this->assertSame('<' . config('app.url') . '/auth>; rel="authorization_endpoint"', $linkHeaders[1]);
|
||||||
|
$this->assertSame('<' . config('app.url') . '/token>; rel="token_endpoint"', $linkHeaders[2]);
|
||||||
|
$this->assertSame('<' . config('app.url') . '/api/post>; rel="micropub"', $linkHeaders[3]);
|
||||||
|
$this->assertSame('<' . config('app.url') . '/webmention>; rel="webmention"', $linkHeaders[4]);
|
||||||
|
}
|
||||||
|
}
|
704
tests/Feature/IndieAuthTest.php
Normal file
704
tests/Feature/IndieAuthTest.php
Normal file
|
@ -0,0 +1,704 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$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'
|
||||||
|
<!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