agent/php/Mod/Api/Middleware/AuthenticateApiKey.php
Snider 5385385314 feat(agent/api): RFC foundation — API keys, webhooks, rate limiting, docs split
Foundation slice for Mantis #844 php/Mod/Api RFC implementation:

* New php/Mod/Api/ package: Boot, Controllers, Documentation, Jobs,
  Middleware, Models, RateLimit, Routes, Services
* Models: ApiKey, WebhookEndpoint, WebhookDelivery
* WebhookService::dispatch() with DB::transaction + afterCommit
* DeliverWebhookJob with retry/backoff
* WebhookSignature with timing-safe verification + 5-minute tolerance +
  dual-secret rotation support
* Sliding-window rate limiter in RateLimit/RateLimitService.php
* AuthenticateApiKey middleware: hk_ prefix + Sanctum fallback
* DocsController / DocumentationController split
* 3 root migrations: api_keys, webhook_endpoints, webhook_deliveries
* Foundation tests under php/tests/Feature/Mod/Api/
* FOLLOWUP.md tracks remaining RFC scope

php -l clean across 21 PHP files. Pest unrunnable in sandbox (no vendor/).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=844
2026-04-25 21:01:54 +01:00

94 lines
2.7 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Mod\Api\Middleware;
use Closure;
use Core\Mod\Agentic\Mod\Api\Models\ApiKey;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateApiKey
{
public function handle(Request $request, Closure $next, ?string $scope = null): Response
{
$token = $request->bearerToken();
if ($token === null || $token === '') {
return $this->unauthorised('API key required. Use Authorization: Bearer <api_key>');
}
if (str_starts_with($token, 'hk_')) {
return $this->authenticateApiKey($request, $next, $token, $scope);
}
return $this->authenticateSanctum($request, $next);
}
protected function authenticateApiKey(Request $request, Closure $next, string $token, ?string $scope): Response
{
$apiKey = ApiKey::findByPlainKey($token);
if (! $apiKey instanceof ApiKey) {
return $this->unauthorised('Invalid API key');
}
if ($apiKey->isExpired()) {
return $this->unauthorised('API key has expired');
}
if ($apiKey->hasIpRestrictions() && ! in_array((string) $request->ip(), $apiKey->getAllowedIps(), true)) {
return $this->forbidden('IP address not allowed for this API key');
}
if ($scope !== null && ! $apiKey->hasScope($scope)) {
return $this->forbidden("API key missing required scope: {$scope}");
}
$apiKey->recordUsage();
$request->setUserResolver(fn () => $apiKey->user);
$request->attributes->set('api_key', $apiKey);
$request->attributes->set('workspace', $apiKey->workspace);
$request->attributes->set('workspace_id', $apiKey->workspace_id);
$request->attributes->set('auth_type', 'api_key');
return $next($request);
}
protected function authenticateSanctum(Request $request, Closure $next): Response
{
if (! $request->user()) {
$guard = auth('sanctum');
if (! $guard->check()) {
return $this->unauthorised('Invalid authentication token');
}
$request->setUserResolver(fn () => $guard->user());
}
$request->attributes->set('auth_type', 'sanctum');
return $next($request);
}
protected function unauthorised(string $message): Response
{
return response()->json([
'error' => 'unauthorised',
'message' => $message,
], 401);
}
protected function forbidden(string $message): Response
{
return response()->json([
'error' => 'forbidden',
'message' => $message,
], 403);
}
}