getTokenFromRequest($request); if (! $token) { return response()->json(['error' => 'Token not provided'], 401); } try { $decoded = $this->decodeToken($token); } catch (\Exception $e) { return response()->json(['error' => 'Invalid token'], 401); } if ($this->isTokenBlacklisted($decoded)) { return response()->json(['error' => 'Token has been revoked'], 401); } if (! empty($scopes) && ! $this->hasScopes($decoded, $scopes)) { return response()->json(['error' => 'Insufficient scopes'], 403); } if (($decoded['exp'] ?? 0) < time()) { $this->blacklistToken($decoded); return response()->json(['error' => 'Token expired'], 401); } $tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null; if (! $tenantId) { return response()->json(['error' => 'Invalid token payload'], 401); } $tenant = Tenant::query()->find($tenantId); if (! $tenant) { return response()->json(['error' => 'Tenant not found'], 404); } $scopesFromToken = $this->normaliseScopes($decoded['scopes'] ?? []); $principal = new GenericUser([ 'id' => $tenant->id, 'tenant_id' => $tenant->id, 'tenant' => $tenant, 'scopes' => $scopesFromToken, 'client_id' => $decoded['client_id'] ?? null, 'jti' => $decoded['jti'] ?? null, 'token_type' => $decoded['type'] ?? 'access', ]); Auth::setUser($principal); $request->setUserResolver(fn () => $principal); $request->merge([ 'tenant_id' => $tenant->id, 'tenant' => $tenant, ]); $request->attributes->set('tenant_id', $tenant->id); $request->attributes->set('tenant', $tenant); $request->attributes->set('decoded_token', $decoded); return $next($request); } /** * Get token from request (Bearer or header) */ private function getTokenFromRequest(Request $request): ?string { $header = $request->header('Authorization'); if (is_string($header) && str_starts_with($header, 'Bearer ')) { return substr($header, 7); } if ($request->header('X-API-Token')) { return $request->header('X-API-Token'); } return null; } /** * Decode JWT token */ private function decodeToken(string $token): array { $kid = $this->extractKid($token); $publicKey = $this->loadPublicKeyForKid($kid); if (! $publicKey) { throw new \Exception('JWT public key not found'); } $decoded = JWT::decode($token, new Key($publicKey, 'RS256')); return (array) $decoded; } private function extractKid(string $token): ?string { $segments = explode('.', $token); if (count($segments) < 2) { return null; } $decodedHeader = json_decode(base64_decode($segments[0]), true); return is_array($decodedHeader) ? ($decodedHeader['kid'] ?? null) : null; } private function loadPublicKeyForKid(?string $kid): ?string { $resolvedKid = $kid ?? config('oauth.keys.current_kid', self::LEGACY_KID); $base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR); $path = $base.DIRECTORY_SEPARATOR.$resolvedKid.DIRECTORY_SEPARATOR.'public.key'; if (File::exists($path)) { return File::get($path); } $legacyPath = storage_path('app/public.key'); if (File::exists($legacyPath)) { return File::get($legacyPath); } return null; } /** * Check if token is blacklisted */ private function isTokenBlacklisted(array $decoded): bool { $jti = $decoded['jti'] ?? null; if (! $jti) { return false; } $cacheKey = "blacklisted_token:{$jti}"; if (Cache::has($cacheKey)) { return true; } $tokenRecord = TenantToken::query()->where('jti', $jti)->first(); if (! $tokenRecord) { return false; } if ($tokenRecord->revoked_at) { Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded)); return true; } if ($tokenRecord->expires_at && $tokenRecord->expires_at->isPast()) { $tokenRecord->update(['revoked_at' => now()]); Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded)); return true; } return false; } /** * Add token to blacklist */ private function blacklistToken(array $decoded): void { $jti = $decoded['jti'] ?? md5(($decoded['sub'] ?? '') . ($decoded['iat'] ?? '')); $cacheKey = "blacklisted_token:{$jti}"; Cache::put($cacheKey, true, $this->cacheTtlFromDecoded($decoded)); $tenantId = $decoded['tenant_id'] ?? $decoded['sub'] ?? null; $tokenType = $decoded['type'] ?? 'access'; $record = TenantToken::query()->where('jti', $jti)->first(); if ($record) { $record->update([ 'revoked_at' => now(), 'expires_at' => $record->expires_at ?? now(), ]); return; } if ($tenantId === null) { return; } TenantToken::create([ 'id' => (string) Str::uuid(), 'tenant_id' => $tenantId, 'jti' => $jti, 'token_type' => $tokenType, 'expires_at' => now(), 'revoked_at' => now(), ]); } /** * Check if token has required scopes */ private function hasScopes(array $decoded, array $requiredScopes): bool { $tokenScopes = $this->normaliseScopes($decoded['scopes'] ?? []); foreach ($requiredScopes as $scope) { if (! in_array($scope, $tokenScopes, true)) { return false; } } return true; } private function normaliseScopes(mixed $scopes): array { if (is_array($scopes)) { return array_values(array_filter($scopes, fn ($scope) => $scope !== null && $scope !== '')); } if (is_string($scopes)) { return array_values(array_filter(explode(' ', $scopes))); } return []; } private function cacheTtlFromDecoded(array $decoded): int { $exp = $decoded['exp'] ?? time(); $ttl = max($exp - time(), 60); return $ttl; } }