Implements the current IndieAuth spec's dedicated /revocation endpoint so clients can self-revoke a token (e.g. on user sign-out), rather than only supporting revocation via the admin side. Always responds 200 per spec, whether the token was found or not, so callers can't use it to probe token validity. Skips the legacy action=revoke-on-/token fallback the spec mentions for older clients, since the only real client here is already being updated to use the current endpoint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014625MfkGZ7GVdbqKme4a8L
346 lines
12 KiB
PHP
346 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\MicropubToken;
|
|
use App\Services\TokenService;
|
|
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\Http;
|
|
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'),
|
|
'revocation_endpoint' => route('indieauth.revocation'),
|
|
'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('scope', '');
|
|
$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,
|
|
'scope' => implode(' ', $request->get('scope', '')),
|
|
];
|
|
|
|
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'),
|
|
]);
|
|
|
|
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.
|
|
*/
|
|
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['scope'] === '') {
|
|
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['scope'],
|
|
];
|
|
$tokenService = resolve(TokenService::class);
|
|
$token = $tokenService->getNewToken($tokenData);
|
|
|
|
return response()->json([
|
|
'access_token' => $token,
|
|
'token_type' => 'Bearer',
|
|
'scope' => $indieAuthData['scope'],
|
|
'me' => config('app.url'),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Process a POST request to the IndieAuth revocation endpoint (RFC 7009).
|
|
*
|
|
* Per spec this always returns HTTP 200, whether the token was revoked,
|
|
* unknown, or already revoked, so callers can't probe token validity.
|
|
*/
|
|
public function processRevocationRequest(Request $request): JsonResponse
|
|
{
|
|
$token = $request->get('token', '');
|
|
|
|
if ($token !== '') {
|
|
MicropubToken::where('token_hash', hash('sha256', $token))
|
|
->whereNull('revoked_at')
|
|
->first()
|
|
?->revoke();
|
|
}
|
|
|
|
return response()->json([], 200);
|
|
}
|
|
|
|
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'])) {
|
|
return false;
|
|
}
|
|
|
|
// If redirect_uri is not a valid URL, then it's not valid
|
|
$redirectUriParsed = \Mf2\parseUriToComponents($redirectUri);
|
|
if (! isset($redirectUriParsed['authority'])) {
|
|
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
|
|
try {
|
|
$clientInfo = Http::throw()->get($clientId);
|
|
} catch (\Throwable) {
|
|
return false;
|
|
}
|
|
|
|
$clientInfoParsed = \Mf2\parse($clientInfo->body(), $clientId);
|
|
|
|
$redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? [];
|
|
|
|
return in_array($redirectUri, $redirectUris, true);
|
|
}
|
|
|
|
/**
|
|
* @throws SodiumException
|
|
*/
|
|
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;
|
|
}
|
|
}
|